ucode-agent 1.26.2 → 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,11 +572,14 @@ 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.',
@@ -511,8 +604,13 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
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({
@@ -1496,6 +1635,20 @@ export class Agent {
1496
1635
  });
1497
1636
  }
1498
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
+
1499
1652
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
1500
1653
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
1501
1654
  if (call.name === 'delegate') return this.delegate(call.args?.tasks);
@@ -1609,9 +1762,11 @@ export class Agent {
1609
1762
  async runWorker(task, index) {
1610
1763
  const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
1611
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.
1612
1767
  const skills = this.skills
1613
1768
  .filter((s) => this.loaded.has(s.name))
1614
- .map((s) => `--- ${s.name} ---\n${s.body}`)
1769
+ .map((s) => `--- ${s.name} ---\n${this.short.has(s.name) && s.digest ? s.digest : s.body}`)
1615
1770
  .join('\n\n');
1616
1771
  const messages = [
1617
1772
  { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
@@ -1845,11 +2000,14 @@ export class Agent {
1845
2000
  });
1846
2001
  }
1847
2002
 
1848
- 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)) {
1849
2006
  return { content: `The "${skill.name}" skill is already loaded above. Follow it.`, summary: 'already loaded' };
1850
2007
  }
1851
2008
 
1852
2009
  this.loaded.add(skill.name);
2010
+ this.short.delete(skill.name);
1853
2011
  this.push(skillMessage(skill));
1854
2012
  return {
1855
2013
  content: `Loaded "${skill.name}". Its instructions are in your context now — follow them.`,
@@ -1887,7 +2045,11 @@ export class Agent {
1887
2045
  { role: 'user', content: forSummary(older) },
1888
2046
  ],
1889
2047
  [],
1890
- { 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 },
1891
2053
  );
1892
2054
  return reply.text;
1893
2055
  },
@@ -2213,6 +2375,7 @@ ${out.content}` });
2213
2375
  this.session = loaded;
2214
2376
  this.working = [...loaded.messages];
2215
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));
2216
2379
  if (loaded.model && MODELS[loaded.model]) setModel(loaded.model);
2217
2380
  return true;
2218
2381
  } catch (err) {
@@ -2263,6 +2426,7 @@ ${out.content}` });
2263
2426
  this.session = newSession(this.cwd, model());
2264
2427
  this.working = [];
2265
2428
  this.loaded = new Set();
2429
+ this.short = new Set(); // loaded as a digest, so load_skill can still fetch the whole thing
2266
2430
  this.showHeader();
2267
2431
  }
2268
2432
 
@@ -2283,8 +2447,9 @@ ${out.content}` });
2283
2447
  this.ui.blank();
2284
2448
  for (const s of this.skills) {
2285
2449
  const live = this.loaded.has(s.name);
2450
+ const how = live && this.short.has(s.name) ? dim(' · short form') : '';
2286
2451
  const auto = s.triggers.length ? dim(' · loads itself') : '';
2287
- this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${auto}`);
2452
+ this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${how}${auto}`);
2288
2453
  this.ui.write(` ${dim(s.description)}`);
2289
2454
  }
2290
2455
  this.ui.blank();
@@ -716,6 +716,12 @@ async function streamed(request, opts, id) {
716
716
  if (call.function?.name) slot.name += call.function.name;
717
717
  if (call.function?.arguments) slot.args += call.function.arguments;
718
718
  partial.set(call.index, slot);
719
+
720
+ // A whole app arrives as one enormous arguments string that takes a
721
+ // minute or two to write. Handing it over as it grows is what lets the
722
+ // caller say which file is being written right now, instead of showing
723
+ // a spinner that has meant nothing for ninety seconds.
724
+ if (call.function?.arguments) opts.onToolArgs?.({ index: call.index, name: slot.name, args: slot.args });
719
725
  }
720
726
  }
721
727