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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/core/loop.js +3022 -3005
- package/src/core/provider.js +116 -53
- package/src/tools/browser.js +13 -4
- package/src/tools/index.js +5 -3
- package/src/tools/scaffold.js +1 -1
package/src/core/provider.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
760
|
+
if (delta.content) {
|
|
761
|
+
text += delta.content;
|
|
762
|
+
opts.onText(delta.content);
|
|
763
|
+
}
|
|
715
764
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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 = [];
|
package/src/tools/browser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.'
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
-
`${
|
|
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':
|
package/src/tools/scaffold.js
CHANGED
|
@@ -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 []; }
|