ucode-agent 1.47.0 → 1.49.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.
@@ -137,6 +137,21 @@ export function fallbackFor(id, tried = new Set()) {
137
137
  /** Seconds to wait on successive rate limits that come with no retry-after. */
138
138
  const RATE_LIMIT_BACKOFF = [5, 10, 20];
139
139
 
140
+ /**
141
+ * How long a stream may go without a single chunk before it counts as frozen.
142
+ *
143
+ * A free endpoint can accept a request and then send nothing at all, and the
144
+ * only thing that used to end that was the five-minute request timeout — five
145
+ * minutes of a spinner, then the same again on the retry. Reasoning streams
146
+ * as it is produced, so even the slowest thinker sends something well inside
147
+ * a minute; silence for that long means nobody is working on the reply.
148
+ */
149
+ export const stallLimit = () => Number(process.env.UCODE_STALL_MS) || 60_000;
150
+
151
+ /** Freezes in a row before ucode stops asking and says to switch models. */
152
+ export const MAX_STALLS = 3;
153
+ let stalls = 0;
154
+
140
155
  let current = process.env.UCODE_MODEL || DEFAULT_MODEL;
141
156
  let client = null;
142
157
 
@@ -622,16 +637,34 @@ export async function ask(messages, tools = [], opts = {}) {
622
637
 
623
638
  for (let attempt = 1; attempt <= attempts; attempt++) {
624
639
  try {
625
- if (opts.onText) return await streamed(request, callOpts, id);
640
+ if (opts.onText) {
641
+ const reply = await streamed(request, callOpts, id);
642
+ stalls = 0;
643
+ return reply;
644
+ }
626
645
  const { data, response } = await connection().chat.completions
627
646
  .create(request, { signal: opts.signal })
628
647
  .withResponse();
629
648
  noteLimits(response?.headers);
649
+ stalls = 0;
630
650
  return normalize(data, id);
631
651
  } catch (err) {
632
652
  noteLimits(err?.headers);
633
653
  problem = explain(err, id);
634
654
 
655
+ // Retrying a model that keeps freezing only repeats the wait. After a
656
+ // few in a row, say so and hand the choice back.
657
+ if (problem.detail?.stalled && ++stalls >= MAX_STALLS) {
658
+ stalls = 0;
659
+ throw new Failure({
660
+ kind: 'stalled',
661
+ attempted: `asking ${modelName(id)} for a reply`,
662
+ failed: `${modelName(id)} froze ${MAX_STALLS} times in a row — it took the request and then sent nothing.`,
663
+ fix: 'Its free endpoint is struggling right now. Run /model and pick another one; North Mini Code answers soonest.',
664
+ cause: problem,
665
+ });
666
+ }
667
+
635
668
  // A per-minute limit is a wait, not a failure. Sit it out rather than
636
669
  // making the user retype their message. Free endpoints often refuse
637
670
  // without saying how long to wait, so when there is no retry-after the
@@ -656,6 +689,7 @@ export async function ask(messages, tools = [], opts = {}) {
656
689
  problem.kind === 'server' || problem.kind === 'network' || problem.kind === 'timeout';
657
690
  if (!worthRetrying || attempt === attempts || opts.signal?.aborted) break;
658
691
  if (printed > 0) break; // half an answer is on screen; do not print it twice
692
+ if (problem.detail?.handed) break; // its first tool calls are already running
659
693
 
660
694
  // A stalled provider needs longer to come back than a dropped socket
661
695
  // does, and the wait is narrated so a slow turn never looks like a hang.
@@ -675,13 +709,17 @@ export async function ask(messages, tools = [], opts = {}) {
675
709
 
676
710
  /** Collect a streamed reply, handing deltas out as they land. */
677
711
  async function streamed(request, opts, id) {
678
- const { data: stream, response } = await connection().chat.completions
679
- .create(
680
- { ...request, stream: true, stream_options: { include_usage: true } },
681
- { signal: opts.signal }
682
- )
683
- .withResponse();
684
- noteLimits(response?.headers);
712
+ // A watchdog of its own, so a frozen stream can be ended without it looking
713
+ // like the user pressed stop.
714
+ const quiet = new AbortController();
715
+ const stop = () => quiet.abort();
716
+ opts.signal?.addEventListener('abort', stop, { once: true });
717
+ let stalled = false;
718
+ let timer;
719
+ const alive = () => {
720
+ clearTimeout(timer);
721
+ timer = setTimeout(() => { stalled = true; quiet.abort(); }, stallLimit());
722
+ };
685
723
 
686
724
  let text = '';
687
725
  let reasoning = '';
@@ -691,57 +729,82 @@ async function streamed(request, opts, id) {
691
729
  const handed = new Set();
692
730
  let highest = -1;
693
731
 
694
- for await (const chunk of stream) {
695
- if (opts.signal?.aborted) break;
696
- if (chunk.usage) usage = chunk.usage;
697
-
698
- const choice = chunk.choices?.[0];
699
- if (!choice) continue;
700
- if (choice.finish_reason) finishReason = choice.finish_reason;
701
- const delta = choice.delta ?? {};
702
-
703
- // Reasoning arrives on a separate channel: `reasoning` on OpenRouter,
704
- // `reasoning_content` on some upstreams.
705
- const thinking = delta.reasoning ?? delta.reasoning_content;
706
- if (thinking) {
707
- reasoning += thinking;
708
- opts.onThinking?.(thinking);
709
- }
732
+ try {
733
+ alive();
734
+ const { data: stream, response } = await connection().chat.completions
735
+ .create(
736
+ { ...request, stream: true, stream_options: { include_usage: true } },
737
+ { signal: quiet.signal }
738
+ )
739
+ .withResponse();
740
+ noteLimits(response?.headers);
741
+
742
+ for await (const chunk of stream) {
743
+ alive();
744
+ if (opts.signal?.aborted) break;
745
+ if (chunk.usage) usage = chunk.usage;
746
+
747
+ const choice = chunk.choices?.[0];
748
+ if (!choice) continue;
749
+ if (choice.finish_reason) finishReason = choice.finish_reason;
750
+ const delta = choice.delta ?? {};
751
+
752
+ // Reasoning arrives on a separate channel: `reasoning` on OpenRouter,
753
+ // `reasoning_content` on some upstreams.
754
+ const thinking = delta.reasoning ?? delta.reasoning_content;
755
+ if (thinking) {
756
+ reasoning += thinking;
757
+ opts.onThinking?.(thinking);
758
+ }
710
759
 
711
- if (delta.content) {
712
- text += delta.content;
713
- opts.onText(delta.content);
714
- }
760
+ if (delta.content) {
761
+ text += delta.content;
762
+ opts.onText(delta.content);
763
+ }
715
764
 
716
- // A tool call's name and arguments arrive across several chunks, keyed by
717
- // index, so they are stitched back together here.
718
- for (const call of delta.tool_calls ?? []) {
719
- // Calls arrive one after another, so the first chunk of call N means
720
- // every call before it is complete. Those are handed over at once, and
721
- // the caller can start running them while the rest are still being
722
- // written — the reply streaming and the tools working overlap.
723
- if (opts.onToolCall && call.index > highest) {
724
- for (const [index, slot] of partial) {
725
- if (index < call.index && !handed.has(index)) {
726
- handed.add(index);
727
- opts.onToolCall(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
765
+ // A tool call's name and arguments arrive across several chunks, keyed by
766
+ // index, so they are stitched back together here.
767
+ for (const call of delta.tool_calls ?? []) {
768
+ // Calls arrive one after another, so the first chunk of call N means
769
+ // every call before it is complete. Those are handed over at once, and
770
+ // the caller can start running them while the rest are still being
771
+ // written — the reply streaming and the tools working overlap.
772
+ if (opts.onToolCall && call.index > highest) {
773
+ for (const [index, slot] of partial) {
774
+ if (index < call.index && !handed.has(index)) {
775
+ handed.add(index);
776
+ opts.onToolCall(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
777
+ }
728
778
  }
779
+ highest = call.index;
729
780
  }
730
- highest = call.index;
731
- }
732
781
 
733
- const slot = partial.get(call.index) ?? { id: '', name: '', args: '' };
734
- if (call.id) slot.id = call.id;
735
- if (call.function?.name) slot.name += call.function.name;
736
- if (call.function?.arguments) slot.args += call.function.arguments;
737
- partial.set(call.index, slot);
738
-
739
- // A whole app arrives as one enormous arguments string that takes a
740
- // minute or two to write. Handing it over as it grows is what lets the
741
- // caller say which file is being written right now, instead of showing
742
- // a spinner that has meant nothing for ninety seconds.
743
- if (call.function?.arguments) opts.onToolArgs?.({ index: call.index, name: slot.name, args: slot.args });
782
+ const slot = partial.get(call.index) ?? { id: '', name: '', args: '' };
783
+ if (call.id) slot.id = call.id;
784
+ if (call.function?.name) slot.name += call.function.name;
785
+ if (call.function?.arguments) slot.args += call.function.arguments;
786
+ partial.set(call.index, slot);
787
+
788
+ // A whole app arrives as one enormous arguments string that takes a
789
+ // minute or two to write. Handing it over as it grows is what lets the
790
+ // caller say which file is being written right now, instead of showing
791
+ // a spinner that has meant nothing for ninety seconds.
792
+ if (call.function?.arguments) opts.onToolArgs?.({ index: call.index, name: slot.name, args: slot.args });
793
+ }
744
794
  }
795
+ } catch (err) {
796
+ if (!stalled || opts.signal?.aborted) throw err;
797
+ throw new Failure({
798
+ kind: 'timeout',
799
+ attempted: `asking ${modelName(id)} for a reply`,
800
+ failed: `${modelName(id)} went silent for ${Math.round(stallLimit() / 1000)}s, so ucode stopped waiting.`,
801
+ fix: 'ucode asks again by itself. If it keeps freezing, /model to North Mini Code.',
802
+ detail: { stalled: true, handed: handed.size },
803
+ cause: err,
804
+ });
805
+ } finally {
806
+ clearTimeout(timer);
807
+ opts.signal?.removeEventListener('abort', stop);
745
808
  }
746
809
 
747
810
  const toolCalls = [];
@@ -188,7 +188,11 @@ export function forgetReviews() {
188
188
  const PROBE_TEXT = 'ucode check';
189
189
 
190
190
  /** Did anything at all happen on the page? */
191
- const moved = (a, b) => a.nodes !== b.nodes || a.text !== b.text || a.stored !== b.stored;
191
+ // The markup itself, not its length: a timer going 25:00 -> 24:59, a counter
192
+ // going 0 -> 1 or a task ticked off by a class change keeps every length the
193
+ // same, and was reported as "NOTHING HAPPENS" — a false accusation the model
194
+ // then "fixed" by rewriting script that worked.
195
+ const moved = (a, b) => a.nodes !== b.nodes || a.html !== b.html || a.stored !== b.stored;
192
196
 
193
197
  /**
194
198
  * Use the app, rather than only looking at it.
@@ -218,6 +222,7 @@ async function useTheApp(page) {
218
222
  const snapshot = () => page.evaluate(() => ({
219
223
  nodes: document.body.querySelectorAll('*').length,
220
224
  text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
225
+ html: document.body.innerHTML,
221
226
  stored: (() => { try { return JSON.stringify(localStorage).length; } catch { return 0; } })(),
222
227
  }));
223
228
 
@@ -326,7 +331,7 @@ export async function withStaticServer(dir, fn) {
326
331
  }
327
332
  }
328
333
 
329
- export async function lookAtApp({ url, paths = ['/'] }) {
334
+ export async function lookAtApp({ url, paths = ['/'], review: withReview = true }) {
330
335
  const base = String(url ?? '').trim().replace(/\/+$/, '');
331
336
  if (!LOCAL.test(`${base}/`)) {
332
337
  throw new ToolFailure({
@@ -355,6 +360,10 @@ export async function lookAtApp({ url, paths = ['/'] }) {
355
360
  const errors = [];
356
361
  const failed = [];
357
362
  page.on('console', (m) => {
363
+ // The browser asks for /favicon.ico on its own. A page without one is
364
+ // not broken, and counting that 404 sent every plain-html build round
365
+ // a fix loop it did not need.
366
+ if (/\/favicon\.ico$/i.test(m.location()?.url ?? '')) return;
358
367
  if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
359
368
  });
360
369
  page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
@@ -513,7 +522,7 @@ export async function lookAtApp({ url, paths = ['/'] }) {
513
522
  // error overlay is a minute spent on nothing.
514
523
  let critique = '';
515
524
  const state = reviews.get(base) ?? { done: false, tries: 0 };
516
- if (!broken && !state.done && state.tries < 2 && toReview.length) {
525
+ if (withReview && !broken && !state.done && state.tries < 2 && toReview.length) {
517
526
  state.tries++;
518
527
  reviews.set(base, state);
519
528
  try {
@@ -530,7 +539,7 @@ export async function lookAtApp({ url, paths = ['/'] }) {
530
539
  critique ? `## Visual review\n${critique}` : '',
531
540
  '',
532
541
  problems
533
- ? 'Fix the problems above, then look again to confirm.'
542
+ ? 'Fix the problems above - only those; leave everything that works as it is.'
534
543
  : state.done && critique
535
544
  ? 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing - ' +
536
545
  'the next look re-runs only the fast checks.'
@@ -12,7 +12,7 @@ import { addBlock, BLOCK_NAMES, PLAIN_BLOCK_NAMES, ALL_BLOCK_NAMES } from './blo
12
12
  import { typeOf } from './types.js';
13
13
  import { runCommand, runCommands } from './shell.js';
14
14
  import { webSearch } from './web.js';
15
- import { createApp, TEMPLATE_NAMES, TEMPLATE_NOTES } from './scaffold.js';
15
+ import { createApp, normaliseFiles, TEMPLATE_NAMES, TEMPLATE_NOTES } from './scaffold.js';
16
16
  import { deploy } from './deploy.js';
17
17
  import { clip, READ_LINES } from './shared.js';
18
18
 
@@ -702,13 +702,15 @@ export function describe(name, args = {}) {
702
702
  return `Running ${args.commands?.length ?? 0} commands together`;
703
703
  case 'deploy':
704
704
  return `Deploying ${clip(args.folder || '.', 30)} to Vercel`;
705
- case 'create_app':
705
+ case 'create_app': {
706
+ const files = normaliseFiles(args.files).length;
706
707
  // Say which starter it actually is. Hardcoding one of them meant a plain
707
708
  // HTML app announced itself as Next.js, which is a line that is simply
708
709
  // untrue on screen while the opposite happens on disk.
709
710
  return `Creating ${clip(args.name || args.folder, 30)} from the ` +
710
711
  `${args.template === 'next-shadcn' ? 'Next.js' : 'HTML'} starter` +
711
- `${args.files?.length ? ` with ${args.files.length} file${args.files.length === 1 ? '' : 's'}` : ''}`;
712
+ `${files ? ` with ${files} file${files === 1 ? '' : 's'}` : ''}`;
713
+ }
712
714
  case 'look_at_app':
713
715
  return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
714
716
  case 'web_search':
@@ -48,7 +48,7 @@ const SHOW_BACK = { 'plain-html': ['index.html', 'styles.css', 'app.js'] };
48
48
  * something adjacent. Each of those, refused, is a round trip spent being
49
49
  * told what could have been read — so they are all read.
50
50
  */
51
- function normaliseFiles(files) {
51
+ export function normaliseFiles(files) {
52
52
  let value = files;
53
53
  if (typeof value === 'string') {
54
54
  try { value = JSON.parse(value); } catch { return []; }