ucode-agent 1.51.0 → 1.53.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 +1 -1
- package/src/core/loop.js +70 -0
- package/src/core/provider.js +28 -10
- package/src/tools/index.js +24 -1
- package/src/tools/scaffold.js +29 -3
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -306,6 +306,9 @@ export function lean(messages, keep = 3) {
|
|
|
306
306
|
});
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/** Tools that write whole files, whose sizes are remembered for the turn. */
|
|
310
|
+
const WHOLE_WRITES = new Set(['write_file', 'batch_write', 'create_app']);
|
|
311
|
+
|
|
309
312
|
function trace(event) {
|
|
310
313
|
if (!TRACE_FILE) return;
|
|
311
314
|
try { appendFileSync(TRACE_FILE, `${JSON.stringify({ at: Date.now(), ...event })}
|
|
@@ -1189,6 +1192,7 @@ export class Agent {
|
|
|
1189
1192
|
this.reads = new Map();
|
|
1190
1193
|
this.declines = 0;
|
|
1191
1194
|
this.apps = [];
|
|
1195
|
+
this.wrote = new Map();
|
|
1192
1196
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1193
1197
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1194
1198
|
const images = await this.attachImages(input);
|
|
@@ -1776,6 +1780,7 @@ export class Agent {
|
|
|
1776
1780
|
// goes on the same line, the way a change carries its two numbers.
|
|
1777
1781
|
// Which app folders this turn actually made, so a second one can be
|
|
1778
1782
|
// refused before it is built and any leftovers can be counted at the end.
|
|
1783
|
+
if (WHOLE_WRITES.has(call.name)) for (const f of this.writesOf(call)) this.wrote?.set(f.abs, f.size);
|
|
1779
1784
|
if (call.name === 'create_app' && call.args?.folder) {
|
|
1780
1785
|
const made = path.resolve(this.cwd, String(call.args.folder));
|
|
1781
1786
|
if (!(this.apps ??= []).includes(made)) this.apps.push(made);
|
|
@@ -1857,6 +1862,21 @@ export class Agent {
|
|
|
1857
1862
|
return err.kind === 'bad_args';
|
|
1858
1863
|
}
|
|
1859
1864
|
|
|
1865
|
+
/** The files a whole-file write names, resolved the way the tool will resolve them. */
|
|
1866
|
+
writesOf(call) {
|
|
1867
|
+
const a = call.args ?? {};
|
|
1868
|
+
const list = call.name === 'write_file' ? [{ path: a.path, content: a.content }] : normaliseFiles(a.files);
|
|
1869
|
+
const home = call.name === 'create_app' && a.folder ? path.resolve(this.cwd, String(a.folder)) : null;
|
|
1870
|
+
return list
|
|
1871
|
+
.filter((f) => typeof f?.path === 'string' && typeof f?.content === 'string')
|
|
1872
|
+
.map((f) => {
|
|
1873
|
+
let abs = path.resolve(this.cwd, f.path);
|
|
1874
|
+
const rel = home ? path.relative(home, abs) : '';
|
|
1875
|
+
if (home && (rel.startsWith('..') || path.isAbsolute(rel))) abs = path.resolve(home, f.path.replace(/^[./\\]+/, ''));
|
|
1876
|
+
return { abs, size: f.content.length, path: f.path };
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1860
1880
|
async dispatch(call) {
|
|
1861
1881
|
// The model emitted arguments that were not valid JSON. Hand the parser's
|
|
1862
1882
|
// own complaint straight back so it can correct itself next step.
|
|
@@ -1883,6 +1903,21 @@ export class Agent {
|
|
|
1883
1903
|
});
|
|
1884
1904
|
}
|
|
1885
1905
|
|
|
1906
|
+
// A read of "index.html" right after create_app put it in tasker/. The
|
|
1907
|
+
// model named the file that way, so it looks for it that way: DeepSeek got
|
|
1908
|
+
// three not-founds and wrote the whole app again. A missing path that
|
|
1909
|
+
// exists inside the one app made this turn is read from there.
|
|
1910
|
+
if ((call.name === 'read_files' || call.name === 'read_file') && this.apps?.length === 1) {
|
|
1911
|
+
const home = this.apps[0];
|
|
1912
|
+
const inApp = (p) => {
|
|
1913
|
+
if (typeof p !== 'string' || existsSync(path.resolve(this.cwd, p))) return p;
|
|
1914
|
+
const there = path.resolve(home, p.replace(/^[./\\]+/, ''));
|
|
1915
|
+
return existsSync(there) ? path.relative(this.cwd, there) : p;
|
|
1916
|
+
};
|
|
1917
|
+
if (call.name === 'read_file') call.args.path = inApp(call.args.path);
|
|
1918
|
+
else if (Array.isArray(call.args?.paths)) call.args.paths = call.args.paths.map(inApp);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1886
1921
|
// Reading the same unchanged file over and over.
|
|
1887
1922
|
//
|
|
1888
1923
|
// A traced build of a one-page app spent 53 of its 80 tool calls on
|
|
@@ -1924,6 +1959,23 @@ export class Agent {
|
|
|
1924
1959
|
const files = args.files;
|
|
1925
1960
|
const none = files == null || files === ''
|
|
1926
1961
|
|| (Array.isArray(files) ? !files.length : typeof files === 'object' && !Object.keys(files).length);
|
|
1962
|
+
// Its files already written this turn, and create_app called over them
|
|
1963
|
+
// again. DeepSeek does this to "verify" — and twice the second version
|
|
1964
|
+
// was the broken one. The first stands; changes go through edit_file.
|
|
1965
|
+
const written = [...(this.wrote?.keys() ?? [])].some((f) => {
|
|
1966
|
+
const rel = path.relative(made, f);
|
|
1967
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
1968
|
+
});
|
|
1969
|
+
if (written && path.resolve(this.cwd, String(args.folder)) === made) {
|
|
1970
|
+
const show = path.relative(this.cwd, made) || '.';
|
|
1971
|
+
throw new ToolFailure({
|
|
1972
|
+
kind: 'already_made',
|
|
1973
|
+
attempted: `creating ${show} again`,
|
|
1974
|
+
failed: `${show} was already created and written this turn; nothing was changed.`,
|
|
1975
|
+
fix: 'The app is on disk as you wrote it. To check it, read it with read_files. To fix '
|
|
1976
|
+
+ 'something in it, use edit_file on that part. Do not call create_app again.',
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1927
1979
|
if (none && path.resolve(this.cwd, String(args.folder)) === made) {
|
|
1928
1980
|
const show = path.relative(this.cwd, made) || '.';
|
|
1929
1981
|
throw new ToolFailure({
|
|
@@ -1936,6 +1988,24 @@ export class Agent {
|
|
|
1936
1988
|
}
|
|
1937
1989
|
}
|
|
1938
1990
|
|
|
1991
|
+
// A file written this turn, about to be replaced by a sliver of itself.
|
|
1992
|
+
// DeepSeek built a 13.6 KB tasker, then "verified" it by writing it again
|
|
1993
|
+
// as 24 bytes, and the app was gone. Checking a file is a read.
|
|
1994
|
+
if (WHOLE_WRITES.has(call.name) && this.wrote?.size) {
|
|
1995
|
+
for (const f of this.writesOf(call)) {
|
|
1996
|
+
const had = this.wrote.get(f.abs);
|
|
1997
|
+
if (had >= 2000 && f.size < had / 4) {
|
|
1998
|
+
throw new ToolFailure({
|
|
1999
|
+
kind: 'would_shrink',
|
|
2000
|
+
attempted: `writing ${f.path}`,
|
|
2001
|
+
failed: `You wrote ${f.path} earlier this turn (${had} characters), and this would replace it with ${f.size}.`,
|
|
2002
|
+
fix: 'Nothing was written. To check a file, read it with read_files; do not write it again. '
|
|
2003
|
+
+ 'To change part of it, use edit_file.',
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1939
2009
|
// Starting a second app instead of fixing the first.
|
|
1940
2010
|
//
|
|
1941
2011
|
// A traced build hit a problem in todo/, abandoned it and made todo-fixed/
|
package/src/core/provider.js
CHANGED
|
@@ -94,6 +94,10 @@ export const MODELS = {
|
|
|
94
94
|
'deepseek/deepseek-v4-flash-0731:free': {
|
|
95
95
|
name: 'DeepSeek V4 Flash',
|
|
96
96
|
context: 1_048_576,
|
|
97
|
+
// Left to itself it thinks for minutes before every step — over two
|
|
98
|
+
// minutes of nothing on screen before its first word. Low effort answers
|
|
99
|
+
// in seconds and still plans the build.
|
|
100
|
+
reasoning: { effort: 'low' },
|
|
97
101
|
note: 'fast DeepSeek coder, 1M context — on trial',
|
|
98
102
|
},
|
|
99
103
|
'qwen/qwen3.8-27b:free': {
|
|
@@ -162,6 +166,9 @@ export const stallLimit = () => Number(process.env.UCODE_STALL_MS) || 60_000;
|
|
|
162
166
|
|
|
163
167
|
/** Freezes in a row before ucode stops asking and says to switch models. */
|
|
164
168
|
export const MAX_STALLS = 3;
|
|
169
|
+
|
|
170
|
+
/** How long a reply may go quiet once it has started writing a tool call. */
|
|
171
|
+
const writingLimit = () => Number(process.env.UCODE_STALL_MS) || 600_000;
|
|
165
172
|
let stalls = 0;
|
|
166
173
|
|
|
167
174
|
let current = process.env.UCODE_MODEL || DEFAULT_MODEL;
|
|
@@ -632,7 +639,8 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
632
639
|
}
|
|
633
640
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
634
641
|
if (opts.maxOutputTokens) request.max_tokens = opts.maxOutputTokens;
|
|
635
|
-
|
|
642
|
+
const reasoning = opts.reasoning ?? MODELS[id]?.reasoning;
|
|
643
|
+
if (reasoning) request.reasoning = reasoning;
|
|
636
644
|
|
|
637
645
|
// A side call (the design review) passes fewer: it is better skipped than
|
|
638
646
|
// waited on through a string of rate-limit pauses.
|
|
@@ -728,9 +736,22 @@ async function streamed(request, opts, id) {
|
|
|
728
736
|
opts.signal?.addEventListener('abort', stop, { once: true });
|
|
729
737
|
let stalled = false;
|
|
730
738
|
let timer;
|
|
739
|
+
const frozen = (cause) => new Failure({
|
|
740
|
+
kind: 'timeout',
|
|
741
|
+
attempted: `asking ${modelName(id)} for a reply`,
|
|
742
|
+
failed: `${modelName(id)} went silent for too long, so ucode stopped waiting.`,
|
|
743
|
+
fix: 'ucode asks again by itself. If it keeps freezing, /model to North Mini Code.',
|
|
744
|
+
detail: { stalled: true, handed: handed.size },
|
|
745
|
+
cause,
|
|
746
|
+
});
|
|
747
|
+
// Once a tool call has begun, the silence may be the call being written.
|
|
748
|
+
// DeepSeek's free upstream holds a create_app back until the whole app is
|
|
749
|
+
// done — two or three quiet minutes — and a one-minute watchdog killed every
|
|
750
|
+
// build it started. So a call in progress gets ten minutes instead.
|
|
731
751
|
const alive = () => {
|
|
732
752
|
clearTimeout(timer);
|
|
733
|
-
|
|
753
|
+
const limit = partial.size ? writingLimit() : stallLimit();
|
|
754
|
+
timer = setTimeout(() => { stalled = true; quiet.abort(); }, limit);
|
|
734
755
|
};
|
|
735
756
|
|
|
736
757
|
let text = '';
|
|
@@ -806,18 +827,15 @@ async function streamed(request, opts, id) {
|
|
|
806
827
|
}
|
|
807
828
|
} catch (err) {
|
|
808
829
|
if (!stalled || opts.signal?.aborted) throw err;
|
|
809
|
-
throw
|
|
810
|
-
kind: 'timeout',
|
|
811
|
-
attempted: `asking ${modelName(id)} for a reply`,
|
|
812
|
-
failed: `${modelName(id)} went silent for ${Math.round(stallLimit() / 1000)}s, so ucode stopped waiting.`,
|
|
813
|
-
fix: 'ucode asks again by itself. If it keeps freezing, /model to North Mini Code.',
|
|
814
|
-
detail: { stalled: true, handed: handed.size },
|
|
815
|
-
cause: err,
|
|
816
|
-
});
|
|
830
|
+
throw frozen(err);
|
|
817
831
|
} finally {
|
|
818
832
|
clearTimeout(timer);
|
|
819
833
|
opts.signal?.removeEventListener('abort', stop);
|
|
820
834
|
}
|
|
835
|
+
// The watchdog's abort can also end the stream quietly instead of throwing.
|
|
836
|
+
// Carrying on from there ran a half-written create_app: JSON repair closed
|
|
837
|
+
// it with no files, and five minutes of app were saved as an empty starter.
|
|
838
|
+
if (stalled && !opts.signal?.aborted) throw frozen();
|
|
821
839
|
|
|
822
840
|
const toolCalls = [];
|
|
823
841
|
for (const [index, slot] of partial) {
|
package/src/tools/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* line the user reads while each one runs.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { jsonrepair } from 'jsonrepair';
|
|
6
7
|
import { ToolFailure } from '../core/failure.js';
|
|
7
8
|
import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit, editFiles } from './files.js';
|
|
8
9
|
import { listDir, glob, grep } from './search.js';
|
|
@@ -573,6 +574,12 @@ export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'm
|
|
|
573
574
|
*/
|
|
574
575
|
const LENIENT = new Set(['create_app.files']);
|
|
575
576
|
|
|
577
|
+
/** Names models use for an argument that the schema calls something else. */
|
|
578
|
+
const ALIASES = {
|
|
579
|
+
path: ['file', 'filename', 'file_path', 'filepath'],
|
|
580
|
+
content: ['contents', 'text', 'code', 'body'],
|
|
581
|
+
};
|
|
582
|
+
|
|
576
583
|
function check(name, args) {
|
|
577
584
|
const schema = tools.find((t) => t.name === name).parameters;
|
|
578
585
|
const problems = [];
|
|
@@ -584,6 +591,14 @@ function check(name, args) {
|
|
|
584
591
|
// One file named as "path" where the tool takes a list called "paths".
|
|
585
592
|
// DeepSeek did it to four read_files at once, a whole round trip spent being
|
|
586
593
|
// told a plural it could simply have been given.
|
|
594
|
+
// The same argument under a neighbouring name: write_file sent "file"
|
|
595
|
+
// instead of "path" cost DeepSeek two and a half minutes to be told so.
|
|
596
|
+
for (const [key, others] of Object.entries(ALIASES)) {
|
|
597
|
+
if (!schema.properties[key] || args[key] != null) continue;
|
|
598
|
+
const other = others.find((o) => args[o] != null && !schema.properties[o]);
|
|
599
|
+
if (other) { args[key] = args[other]; delete args[other]; }
|
|
600
|
+
}
|
|
601
|
+
|
|
587
602
|
for (const key of schema.required ?? []) {
|
|
588
603
|
const one = key.endsWith('s') ? key.slice(0, -1) : '';
|
|
589
604
|
if (args[key] == null && one && !schema.properties[one] && args[one] != null
|
|
@@ -619,7 +634,15 @@ function check(name, args) {
|
|
|
619
634
|
const parsed = JSON.parse(value);
|
|
620
635
|
const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
621
636
|
if (kind === wanted || LENIENT.has(`${name}.${key}`)) { args[key] = parsed; actual = kind; }
|
|
622
|
-
} catch {
|
|
637
|
+
} catch {
|
|
638
|
+
// Nearly JSON: DeepSeek sent a whole app's files this way and was
|
|
639
|
+
// told only that a string is not an array. Repair it before refusing.
|
|
640
|
+
try {
|
|
641
|
+
const parsed = JSON.parse(jsonrepair(value));
|
|
642
|
+
const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
643
|
+
if (kind === wanted) { args[key] = parsed; actual = kind; }
|
|
644
|
+
} catch { /* not JSON either — the message below is the right answer */ }
|
|
645
|
+
}
|
|
623
646
|
}
|
|
624
647
|
|
|
625
648
|
// A list of files written as a { path: contents } map. The tool reads it
|
package/src/tools/scaffold.js
CHANGED
|
@@ -431,6 +431,18 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
431
431
|
// The app's own files, written in this same call. Two round trips become
|
|
432
432
|
// one, and round trips are nearly all of the time a build takes.
|
|
433
433
|
// Next.js only: paths that have missed src/ are corrected before they land.
|
|
434
|
+
// Paths are relative to the project root, but a model will name a file
|
|
435
|
+
// "index.html" meaning the app's own. DeepSeek did: its tasker went to the
|
|
436
|
+
// project root and the starter was left in tasker/ as the app. A path that
|
|
437
|
+
// is not already under the folder is put under it.
|
|
438
|
+
const home = resolveIn(folder, 'create_app', 'folder').abs;
|
|
439
|
+
for (const f of given) {
|
|
440
|
+
const abs = resolveIn(f.path, 'create_app', 'files').abs;
|
|
441
|
+
const rel = path.relative(home, abs);
|
|
442
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
443
|
+
f.path = path.join(String(folder), String(f.path).replace(/^[./\\]+/, ''));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
434
446
|
const placed = template === 'next-shadcn' ? placeForNext(given, path.basename(target.abs)) : { files: given, moved: [] };
|
|
435
447
|
const mine = placed.files;
|
|
436
448
|
const wrote = mine.length ? await batchWrite({ files: mine }) : null;
|
|
@@ -442,11 +454,15 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
442
454
|
// back — and only the ones this call did not already write over. A read is
|
|
443
455
|
// another round trip to learn what ucode already knows.
|
|
444
456
|
const starter = [];
|
|
457
|
+
const leftover = [];
|
|
445
458
|
for (const rel of SHOW_BACK[template] ?? []) {
|
|
446
459
|
const abs = path.join(target.abs, rel);
|
|
447
460
|
if (written.has(abs)) continue;
|
|
448
461
|
const text = await fs.readFile(abs, 'utf8').catch(() => null);
|
|
449
|
-
if (text !== null)
|
|
462
|
+
if (text !== null) {
|
|
463
|
+
starter.push(`=== ${target.show}/${rel} ===\n${text}`);
|
|
464
|
+
leftover.push(`${target.show}/${rel}`);
|
|
465
|
+
}
|
|
450
466
|
}
|
|
451
467
|
|
|
452
468
|
const out = result(
|
|
@@ -489,8 +505,18 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
489
505
|
(needsInstall
|
|
490
506
|
? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
|
|
491
507
|
: `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
|
|
492
|
-
`folder with "python -m http.server 8000" if it fetches anything.\n
|
|
493
|
-
|
|
508
|
+
`folder with "python -m http.server 8000" if it fetches anything.\n` + (mine.length ? '' : `\n${guide}`)) +
|
|
509
|
+
// With the app passed in, the starter's placeholder files printed in full
|
|
510
|
+
// read as the app having been overwritten: DeepSeek took them for that
|
|
511
|
+
// and wrote the whole app a second time. So they are only named.
|
|
512
|
+
(mine.length
|
|
513
|
+
? '\nYour files are on disk exactly as written above; nothing overwrote them.'
|
|
514
|
+
+ (starter.length
|
|
515
|
+
? ` Starter files you did not replace were left as they were: ${leftover.join(', ')}.`
|
|
516
|
+
: '')
|
|
517
|
+
+ ' Do not write the app again: to check it, read it with read_files, and to change a '
|
|
518
|
+
+ 'part of it, use edit_file. Writing it out again to verify it costs minutes and checks nothing.'
|
|
519
|
+
: starter.length
|
|
494
520
|
? `\n\nThe starter's files, in full — they are below, so do not read them back:\n\n${starter.join('\n\n')}`
|
|
495
521
|
: ''),
|
|
496
522
|
`${copied.length} files${wrote ? ` · ${mine.length} written` : ''}` +
|