ucode-agent 1.52.0 → 1.54.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 +196 -3
- package/src/core/provider.js +15 -10
- package/src/tools/index.js +29 -1
- package/src/tools/scaffold.js +17 -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 })}
|
|
@@ -360,7 +363,9 @@ const MAX_WORKERS = 3;
|
|
|
360
363
|
const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
|
|
361
364
|
|
|
362
365
|
/** Workers build; they do not plan, delegate further, or load skills themselves. */
|
|
363
|
-
|
|
366
|
+
// create_app too: a worker builds a part of the app the lead already made,
|
|
367
|
+
// and a second app from a worker would also end the lead's turn (see wrapUp).
|
|
368
|
+
const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill', 'create_app']);
|
|
364
369
|
|
|
365
370
|
const planTool = {
|
|
366
371
|
name: 'update_plan',
|
|
@@ -458,6 +463,28 @@ function pathsOf(call) {
|
|
|
458
463
|
return a.path ? [a.path] : [];
|
|
459
464
|
}
|
|
460
465
|
|
|
466
|
+
/**
|
|
467
|
+
* A relative path with the project's own trailing folder names in front of
|
|
468
|
+
* it, taken back off: "scratchpad/run1/taskr" inside .../scratchpad/run1 is
|
|
469
|
+
* "taskr". Left alone when something by that first name is really there.
|
|
470
|
+
* ponytail: a genuinely new folder named like the project's own (run1/ inside
|
|
471
|
+
* run1) lands one level up; tell the two apart if that ever happens for real.
|
|
472
|
+
*/
|
|
473
|
+
export function unrepeat(p, cwd) {
|
|
474
|
+
if (!p || path.isAbsolute(p)) return p;
|
|
475
|
+
const parts = p.split(/[\\/]+/);
|
|
476
|
+
if (parts.length < 2 || existsSync(path.join(cwd, parts[0]))) return p;
|
|
477
|
+
const own = path.resolve(cwd).split(/[\\/]+/).filter(Boolean);
|
|
478
|
+
const same = process.platform === 'win32' || process.platform === 'darwin'
|
|
479
|
+
? (x, y) => x.toLowerCase() === y.toLowerCase()
|
|
480
|
+
: (x, y) => x === y;
|
|
481
|
+
for (let n = Math.min(own.length, parts.length - 1); n > 0; n--) {
|
|
482
|
+
const tail = own.slice(-n);
|
|
483
|
+
if (tail.every((s, i) => same(s, parts[i]))) return parts.slice(n).join('/');
|
|
484
|
+
}
|
|
485
|
+
return p;
|
|
486
|
+
}
|
|
487
|
+
|
|
461
488
|
/**
|
|
462
489
|
* Is everything that changed part of a page that has nothing to run?
|
|
463
490
|
*
|
|
@@ -1189,6 +1216,8 @@ export class Agent {
|
|
|
1189
1216
|
this.reads = new Map();
|
|
1190
1217
|
this.declines = 0;
|
|
1191
1218
|
this.apps = [];
|
|
1219
|
+
this.wrote = new Map();
|
|
1220
|
+
this.rewrite = null; // an Esc mid-cut must not end the next turn early
|
|
1192
1221
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1193
1222
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1194
1223
|
const images = await this.attachImages(input);
|
|
@@ -1308,10 +1337,13 @@ export class Agent {
|
|
|
1308
1337
|
let reply;
|
|
1309
1338
|
let streaming = false;
|
|
1310
1339
|
this.early = new Map();
|
|
1340
|
+
// Its own signal, so one reply can be cut off without ending the turn.
|
|
1341
|
+
// any() also sees an Esc that landed before this step began.
|
|
1342
|
+
const cut = new AbortController();
|
|
1311
1343
|
|
|
1312
1344
|
try {
|
|
1313
1345
|
const opts = {
|
|
1314
|
-
signal: this.abort.signal,
|
|
1346
|
+
signal: AbortSignal.any([this.abort.signal, cut.signal]),
|
|
1315
1347
|
onWait: (text) => this.ui.updateSpinner(text),
|
|
1316
1348
|
};
|
|
1317
1349
|
// Only a real terminal has somewhere to stream into.
|
|
@@ -1340,6 +1372,9 @@ export class Agent {
|
|
|
1340
1372
|
// written now instead of sitting on "working" for ninety seconds.
|
|
1341
1373
|
const writing = new Writing();
|
|
1342
1374
|
opts.onToolArgs = ({ index, name, args }) => {
|
|
1375
|
+
// create_app again over the app it already wrote. It would be
|
|
1376
|
+
// refused anyway, after another ninety seconds of streaming it.
|
|
1377
|
+
if (name === 'create_app' && !this.rewrite && (this.rewrite = this.builtApp())) cut.abort();
|
|
1343
1378
|
const at = writing.seen(index, name, args);
|
|
1344
1379
|
if (at) this.ui.updateSpinner(at);
|
|
1345
1380
|
};
|
|
@@ -1368,6 +1403,12 @@ export class Agent {
|
|
|
1368
1403
|
this.ui.thinkingEnd();
|
|
1369
1404
|
if (streaming) this.ui.streamEnd();
|
|
1370
1405
|
|
|
1406
|
+
if (this.rewrite && !this.abort.signal.aborted) {
|
|
1407
|
+
this.ui.stopSpinner();
|
|
1408
|
+
if (await this.wrapUp(fixRounds++)) continue;
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1371
1412
|
// The model invented a tool and the provider rejected the request
|
|
1372
1413
|
// outright. Tell it what it did and let it try again.
|
|
1373
1414
|
if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
|
|
@@ -1412,6 +1453,13 @@ export class Agent {
|
|
|
1412
1453
|
// the thinking timer has to be closed out here as well.
|
|
1413
1454
|
this.ui.thinkingEnd();
|
|
1414
1455
|
this.ui.stopSpinner();
|
|
1456
|
+
// Cut off while writing the app again, but the stream ended quietly
|
|
1457
|
+
// instead of throwing. Its half-written calls must not run.
|
|
1458
|
+
if (this.rewrite && !this.abort.signal.aborted) {
|
|
1459
|
+
if (streaming) this.ui.streamEnd();
|
|
1460
|
+
if (await this.wrapUp(fixRounds++)) continue;
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1415
1463
|
this.record(reply.usage);
|
|
1416
1464
|
this.ui.step?.();
|
|
1417
1465
|
this.stats.steps++;
|
|
@@ -1540,6 +1588,10 @@ export class Agent {
|
|
|
1540
1588
|
|
|
1541
1589
|
const badArgs = await this.runCalls(reply.toolCalls);
|
|
1542
1590
|
if (this.abort.signal.aborted) return;
|
|
1591
|
+
if (this.rewrite) {
|
|
1592
|
+
if (await this.wrapUp(fixRounds++)) continue;
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1543
1595
|
|
|
1544
1596
|
// Malformed arguments go back to the model, but only so many times.
|
|
1545
1597
|
if (badArgs) {
|
|
@@ -1776,6 +1828,7 @@ export class Agent {
|
|
|
1776
1828
|
// goes on the same line, the way a change carries its two numbers.
|
|
1777
1829
|
// Which app folders this turn actually made, so a second one can be
|
|
1778
1830
|
// refused before it is built and any leftovers can be counted at the end.
|
|
1831
|
+
if (WHOLE_WRITES.has(call.name)) for (const f of this.writesOf(call)) this.wrote?.set(f.abs, f.size);
|
|
1779
1832
|
if (call.name === 'create_app' && call.args?.folder) {
|
|
1780
1833
|
const made = path.resolve(this.cwd, String(call.args.folder));
|
|
1781
1834
|
if (!(this.apps ??= []).includes(made)) this.apps.push(made);
|
|
@@ -1826,7 +1879,9 @@ export class Agent {
|
|
|
1826
1879
|
// to the model, which can act on it, and not to the screen. A refusal the
|
|
1827
1880
|
// user made, and anything that actually failed, still shows.
|
|
1828
1881
|
if (err instanceof Declined) this.ui.toolFailed('declined');
|
|
1829
|
-
|
|
1882
|
+
// A rewrite of the finished app is not a failure the user needs to see:
|
|
1883
|
+
// the turn is about to end on the app already written.
|
|
1884
|
+
else if (err.kind !== 'bad_args' && !this.rewrite) this.ui.toolFailed(`${err.kind}: ${err.failed}`);
|
|
1830
1885
|
|
|
1831
1886
|
// Declines that keep coming are not decisions, they are a wall.
|
|
1832
1887
|
//
|
|
@@ -1857,6 +1912,69 @@ export class Agent {
|
|
|
1857
1912
|
return err.kind === 'bad_args';
|
|
1858
1913
|
}
|
|
1859
1914
|
|
|
1915
|
+
/** The app folder this turn already wrote a real file into (2 KB or more), or null. */
|
|
1916
|
+
builtApp() {
|
|
1917
|
+
for (const app of this.apps ?? []) {
|
|
1918
|
+
for (const [abs, size] of this.wrote ?? []) {
|
|
1919
|
+
const rel = path.relative(app, abs);
|
|
1920
|
+
if (size >= 2000 && !rel.startsWith('..') && !path.isAbsolute(rel)) return app;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
return null;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* End a turn whose app is already written, without asking the model again.
|
|
1928
|
+
*
|
|
1929
|
+
* DeepSeek writes the whole app in its first create_app, then writes it
|
|
1930
|
+
* again — six more times in one traced build, ninety seconds each, every one
|
|
1931
|
+
* refused. Refusing hands the turn straight back, so on screen the build
|
|
1932
|
+
* kept starting over and never said it was done. The app on disk is the
|
|
1933
|
+
* answer: check it once, hand back real errors, otherwise say so and stop.
|
|
1934
|
+
*
|
|
1935
|
+
* Returns true when there are errors for the model to fix.
|
|
1936
|
+
*/
|
|
1937
|
+
async wrapUp(fixRounds) {
|
|
1938
|
+
const app = this.rewrite;
|
|
1939
|
+
this.rewrite = null;
|
|
1940
|
+
const show = path.relative(this.cwd, app).split(path.sep).join('/') || '.';
|
|
1941
|
+
// Everything this turn wrote, by where it really landed: create_app moves
|
|
1942
|
+
// bare names into its folder, so the names the model used can miss.
|
|
1943
|
+
for (const abs of this.wrote.keys()) this.sinceCheck.add(path.relative(this.cwd, abs));
|
|
1944
|
+
const problems = await this.autoCheck();
|
|
1945
|
+
if (problems && fixRounds < MAX_FIX_ROUNDS) {
|
|
1946
|
+
this.push({
|
|
1947
|
+
role: 'user',
|
|
1948
|
+
content:
|
|
1949
|
+
`The app is already written in ${show}; do not call create_app again. ucode checked it ` +
|
|
1950
|
+
`and found errors. Fix them with edit_file, the smallest edits that do it, then finish.\n\n${problems}`,
|
|
1951
|
+
});
|
|
1952
|
+
return true;
|
|
1953
|
+
}
|
|
1954
|
+
const page = existsSync(path.join(app, 'index.html')) ? `${show}/index.html` : show;
|
|
1955
|
+
const text = problems
|
|
1956
|
+
? `The app is in \`${show}\`, but the check still finds problems:\n\n${problems}`
|
|
1957
|
+
: `Done — the app is in \`${show}\`. Open \`${page}\` in your browser to try it.`;
|
|
1958
|
+
this.push({ role: 'assistant', content: text });
|
|
1959
|
+
this.ui.assistant(text, { closing: true });
|
|
1960
|
+
return false;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** The files a whole-file write names, resolved the way the tool will resolve them. */
|
|
1964
|
+
writesOf(call) {
|
|
1965
|
+
const a = call.args ?? {};
|
|
1966
|
+
const list = call.name === 'write_file' ? [{ path: a.path, content: a.content }] : normaliseFiles(a.files);
|
|
1967
|
+
const home = call.name === 'create_app' && a.folder ? path.resolve(this.cwd, String(a.folder)) : null;
|
|
1968
|
+
return list
|
|
1969
|
+
.filter((f) => typeof f?.path === 'string' && typeof f?.content === 'string')
|
|
1970
|
+
.map((f) => {
|
|
1971
|
+
let abs = path.resolve(this.cwd, f.path);
|
|
1972
|
+
const rel = home ? path.relative(home, abs) : '';
|
|
1973
|
+
if (home && (rel.startsWith('..') || path.isAbsolute(rel))) abs = path.resolve(home, f.path.replace(/^[./\\]+/, ''));
|
|
1974
|
+
return { abs, size: f.content.length, path: f.path };
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1860
1978
|
async dispatch(call) {
|
|
1861
1979
|
// The model emitted arguments that were not valid JSON. Hand the parser's
|
|
1862
1980
|
// own complaint straight back so it can correct itself next step.
|
|
@@ -1883,6 +2001,33 @@ export class Agent {
|
|
|
1883
2001
|
});
|
|
1884
2002
|
}
|
|
1885
2003
|
|
|
2004
|
+
// Paths that start by repeating the project's own folder names. Told the
|
|
2005
|
+
// working directory was .../scratchpad/run1, DeepSeek made its app at
|
|
2006
|
+
// scratchpad/run1/taskr — the tree copied inside itself — and every later
|
|
2007
|
+
// read of taskr/ missed, so it wrote the whole app again, six times.
|
|
2008
|
+
const a = call.args;
|
|
2009
|
+
if (a && typeof a === 'object') {
|
|
2010
|
+
const fix = (p) => unrepeat(p, this.cwd);
|
|
2011
|
+
for (const k of ['folder', 'path']) if (typeof a[k] === 'string') a[k] = fix(a[k]);
|
|
2012
|
+
if (Array.isArray(a.paths)) a.paths = a.paths.map(fix);
|
|
2013
|
+
if (Array.isArray(a.files)) for (const f of a.files) if (typeof f?.path === 'string') f.path = fix(f.path);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// A read of "index.html" right after create_app put it in tasker/. The
|
|
2017
|
+
// model named the file that way, so it looks for it that way: DeepSeek got
|
|
2018
|
+
// three not-founds and wrote the whole app again. A missing path that
|
|
2019
|
+
// exists inside the one app made this turn is read from there.
|
|
2020
|
+
if ((call.name === 'read_files' || call.name === 'read_file') && this.apps?.length === 1) {
|
|
2021
|
+
const home = this.apps[0];
|
|
2022
|
+
const inApp = (p) => {
|
|
2023
|
+
if (typeof p !== 'string' || existsSync(path.resolve(this.cwd, p))) return p;
|
|
2024
|
+
const there = path.resolve(home, p.replace(/^[./\\]+/, ''));
|
|
2025
|
+
return existsSync(there) ? path.relative(this.cwd, there) : p;
|
|
2026
|
+
};
|
|
2027
|
+
if (call.name === 'read_file') call.args.path = inApp(call.args.path);
|
|
2028
|
+
else if (Array.isArray(call.args?.paths)) call.args.paths = call.args.paths.map(inApp);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
1886
2031
|
// Reading the same unchanged file over and over.
|
|
1887
2032
|
//
|
|
1888
2033
|
// A traced build of a one-page app spent 53 of its 80 tool calls on
|
|
@@ -1911,6 +2056,19 @@ export class Agent {
|
|
|
1911
2056
|
this.reads.set(key, seen + 1);
|
|
1912
2057
|
}
|
|
1913
2058
|
|
|
2059
|
+
// A whole app already written this turn, and create_app called again. The
|
|
2060
|
+
// turn ends here (see wrapUp) instead of handing the refusal back to be
|
|
2061
|
+
// retried: DeepSeek wrote the finished app, then wrote it six more times.
|
|
2062
|
+
if (call.name === 'create_app' && (this.rewrite = this.builtApp())) {
|
|
2063
|
+
const show = path.relative(this.cwd, this.rewrite) || '.';
|
|
2064
|
+
throw new ToolFailure({
|
|
2065
|
+
kind: 'already_made',
|
|
2066
|
+
attempted: `creating ${show} again`,
|
|
2067
|
+
failed: `${show} was already written this turn; nothing was changed.`,
|
|
2068
|
+
fix: 'Do not call create_app again. To fix something in it, use edit_file on that part.',
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1914
2072
|
// create_app again for the app this turn already made. DeepSeek scaffolded
|
|
1915
2073
|
// tasker with no files, then called create_app twice more — once with no
|
|
1916
2074
|
// folder or name at all — and was refused both times while the empty
|
|
@@ -1924,6 +2082,23 @@ export class Agent {
|
|
|
1924
2082
|
const files = args.files;
|
|
1925
2083
|
const none = files == null || files === ''
|
|
1926
2084
|
|| (Array.isArray(files) ? !files.length : typeof files === 'object' && !Object.keys(files).length);
|
|
2085
|
+
// Its files already written this turn, and create_app called over them
|
|
2086
|
+
// again. DeepSeek does this to "verify" — and twice the second version
|
|
2087
|
+
// was the broken one. The first stands; changes go through edit_file.
|
|
2088
|
+
const written = [...(this.wrote?.keys() ?? [])].some((f) => {
|
|
2089
|
+
const rel = path.relative(made, f);
|
|
2090
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
2091
|
+
});
|
|
2092
|
+
if (written && path.resolve(this.cwd, String(args.folder)) === made) {
|
|
2093
|
+
const show = path.relative(this.cwd, made) || '.';
|
|
2094
|
+
throw new ToolFailure({
|
|
2095
|
+
kind: 'already_made',
|
|
2096
|
+
attempted: `creating ${show} again`,
|
|
2097
|
+
failed: `${show} was already created and written this turn; nothing was changed.`,
|
|
2098
|
+
fix: 'The app is on disk as you wrote it. To check it, read it with read_files. To fix '
|
|
2099
|
+
+ 'something in it, use edit_file on that part. Do not call create_app again.',
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
1927
2102
|
if (none && path.resolve(this.cwd, String(args.folder)) === made) {
|
|
1928
2103
|
const show = path.relative(this.cwd, made) || '.';
|
|
1929
2104
|
throw new ToolFailure({
|
|
@@ -1936,6 +2111,24 @@ export class Agent {
|
|
|
1936
2111
|
}
|
|
1937
2112
|
}
|
|
1938
2113
|
|
|
2114
|
+
// A file written this turn, about to be replaced by a sliver of itself.
|
|
2115
|
+
// DeepSeek built a 13.6 KB tasker, then "verified" it by writing it again
|
|
2116
|
+
// as 24 bytes, and the app was gone. Checking a file is a read.
|
|
2117
|
+
if (WHOLE_WRITES.has(call.name) && this.wrote?.size) {
|
|
2118
|
+
for (const f of this.writesOf(call)) {
|
|
2119
|
+
const had = this.wrote.get(f.abs);
|
|
2120
|
+
if (had >= 2000 && f.size < had / 4) {
|
|
2121
|
+
throw new ToolFailure({
|
|
2122
|
+
kind: 'would_shrink',
|
|
2123
|
+
attempted: `writing ${f.path}`,
|
|
2124
|
+
failed: `You wrote ${f.path} earlier this turn (${had} characters), and this would replace it with ${f.size}.`,
|
|
2125
|
+
fix: 'Nothing was written. To check a file, read it with read_files; do not write it again. '
|
|
2126
|
+
+ 'To change part of it, use edit_file.',
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1939
2132
|
// Starting a second app instead of fixing the first.
|
|
1940
2133
|
//
|
|
1941
2134
|
// A traced build hit a problem in todo/, abandoned it and made todo-fixed/
|
package/src/core/provider.js
CHANGED
|
@@ -168,7 +168,7 @@ export const stallLimit = () => Number(process.env.UCODE_STALL_MS) || 60_000;
|
|
|
168
168
|
export const MAX_STALLS = 3;
|
|
169
169
|
|
|
170
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) ||
|
|
171
|
+
const writingLimit = () => Number(process.env.UCODE_STALL_MS) || 600_000;
|
|
172
172
|
let stalls = 0;
|
|
173
173
|
|
|
174
174
|
let current = process.env.UCODE_MODEL || DEFAULT_MODEL;
|
|
@@ -736,10 +736,18 @@ async function streamed(request, opts, id) {
|
|
|
736
736
|
opts.signal?.addEventListener('abort', stop, { once: true });
|
|
737
737
|
let stalled = false;
|
|
738
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
|
+
});
|
|
739
747
|
// Once a tool call has begun, the silence may be the call being written.
|
|
740
748
|
// DeepSeek's free upstream holds a create_app back until the whole app is
|
|
741
749
|
// done — two or three quiet minutes — and a one-minute watchdog killed every
|
|
742
|
-
// build it started. So a call in progress gets
|
|
750
|
+
// build it started. So a call in progress gets ten minutes instead.
|
|
743
751
|
const alive = () => {
|
|
744
752
|
clearTimeout(timer);
|
|
745
753
|
const limit = partial.size ? writingLimit() : stallLimit();
|
|
@@ -819,18 +827,15 @@ async function streamed(request, opts, id) {
|
|
|
819
827
|
}
|
|
820
828
|
} catch (err) {
|
|
821
829
|
if (!stalled || opts.signal?.aborted) throw err;
|
|
822
|
-
throw
|
|
823
|
-
kind: 'timeout',
|
|
824
|
-
attempted: `asking ${modelName(id)} for a reply`,
|
|
825
|
-
failed: `${modelName(id)} went silent for ${Math.round(stallLimit() / 1000)}s, so ucode stopped waiting.`,
|
|
826
|
-
fix: 'ucode asks again by itself. If it keeps freezing, /model to North Mini Code.',
|
|
827
|
-
detail: { stalled: true, handed: handed.size },
|
|
828
|
-
cause: err,
|
|
829
|
-
});
|
|
830
|
+
throw frozen(err);
|
|
830
831
|
} finally {
|
|
831
832
|
clearTimeout(timer);
|
|
832
833
|
opts.signal?.removeEventListener('abort', stop);
|
|
833
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();
|
|
834
839
|
|
|
835
840
|
const toolCalls = [];
|
|
836
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,13 @@ 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
|
+
paths: ['files'],
|
|
582
|
+
};
|
|
583
|
+
|
|
576
584
|
function check(name, args) {
|
|
577
585
|
const schema = tools.find((t) => t.name === name).parameters;
|
|
578
586
|
const problems = [];
|
|
@@ -584,6 +592,18 @@ function check(name, args) {
|
|
|
584
592
|
// One file named as "path" where the tool takes a list called "paths".
|
|
585
593
|
// DeepSeek did it to four read_files at once, a whole round trip spent being
|
|
586
594
|
// told a plural it could simply have been given.
|
|
595
|
+
// The same argument under a neighbouring name: write_file sent "file"
|
|
596
|
+
// instead of "path" cost DeepSeek two and a half minutes to be told so.
|
|
597
|
+
for (const [key, others] of Object.entries(ALIASES)) {
|
|
598
|
+
if (!schema.properties[key] || args[key] != null) continue;
|
|
599
|
+
const other = others.find((o) => args[o] != null && !schema.properties[o]);
|
|
600
|
+
if (other) { args[key] = args[other]; delete args[other]; }
|
|
601
|
+
}
|
|
602
|
+
// read_files sent as files: [{ path }], the shape batch_write takes.
|
|
603
|
+
if (Array.isArray(args.paths)) {
|
|
604
|
+
args.paths = args.paths.map((p) => (typeof p?.path === 'string' ? p.path : p));
|
|
605
|
+
}
|
|
606
|
+
|
|
587
607
|
for (const key of schema.required ?? []) {
|
|
588
608
|
const one = key.endsWith('s') ? key.slice(0, -1) : '';
|
|
589
609
|
if (args[key] == null && one && !schema.properties[one] && args[one] != null
|
|
@@ -619,7 +639,15 @@ function check(name, args) {
|
|
|
619
639
|
const parsed = JSON.parse(value);
|
|
620
640
|
const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
621
641
|
if (kind === wanted || LENIENT.has(`${name}.${key}`)) { args[key] = parsed; actual = kind; }
|
|
622
|
-
} catch {
|
|
642
|
+
} catch {
|
|
643
|
+
// Nearly JSON: DeepSeek sent a whole app's files this way and was
|
|
644
|
+
// told only that a string is not an array. Repair it before refusing.
|
|
645
|
+
try {
|
|
646
|
+
const parsed = JSON.parse(jsonrepair(value));
|
|
647
|
+
const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
648
|
+
if (kind === wanted) { args[key] = parsed; actual = kind; }
|
|
649
|
+
} catch { /* not JSON either — the message below is the right answer */ }
|
|
650
|
+
}
|
|
623
651
|
}
|
|
624
652
|
|
|
625
653
|
// A list of files written as a { path: contents } map. The tool reads it
|
package/src/tools/scaffold.js
CHANGED
|
@@ -454,11 +454,15 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
454
454
|
// back — and only the ones this call did not already write over. A read is
|
|
455
455
|
// another round trip to learn what ucode already knows.
|
|
456
456
|
const starter = [];
|
|
457
|
+
const leftover = [];
|
|
457
458
|
for (const rel of SHOW_BACK[template] ?? []) {
|
|
458
459
|
const abs = path.join(target.abs, rel);
|
|
459
460
|
if (written.has(abs)) continue;
|
|
460
461
|
const text = await fs.readFile(abs, 'utf8').catch(() => null);
|
|
461
|
-
if (text !== null)
|
|
462
|
+
if (text !== null) {
|
|
463
|
+
starter.push(`=== ${target.show}/${rel} ===\n${text}`);
|
|
464
|
+
leftover.push(`${target.show}/${rel}`);
|
|
465
|
+
}
|
|
462
466
|
}
|
|
463
467
|
|
|
464
468
|
const out = result(
|
|
@@ -501,8 +505,18 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
501
505
|
(needsInstall
|
|
502
506
|
? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
|
|
503
507
|
: `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
|
|
504
|
-
`folder with "python -m http.server 8000" if it fetches anything.\n
|
|
505
|
-
|
|
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
|
|
506
520
|
? `\n\nThe starter's files, in full — they are below, so do not read them back:\n\n${starter.join('\n\n')}`
|
|
507
521
|
: ''),
|
|
508
522
|
`${copied.length} files${wrote ? ` · ${mine.length} written` : ''}` +
|