ucode-agent 1.53.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 +126 -3
- package/src/tools/index.js +5 -0
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -363,7 +363,9 @@ const MAX_WORKERS = 3;
|
|
|
363
363
|
const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
|
|
364
364
|
|
|
365
365
|
/** Workers build; they do not plan, delegate further, or load skills themselves. */
|
|
366
|
-
|
|
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']);
|
|
367
369
|
|
|
368
370
|
const planTool = {
|
|
369
371
|
name: 'update_plan',
|
|
@@ -461,6 +463,28 @@ function pathsOf(call) {
|
|
|
461
463
|
return a.path ? [a.path] : [];
|
|
462
464
|
}
|
|
463
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
|
+
|
|
464
488
|
/**
|
|
465
489
|
* Is everything that changed part of a page that has nothing to run?
|
|
466
490
|
*
|
|
@@ -1193,6 +1217,7 @@ export class Agent {
|
|
|
1193
1217
|
this.declines = 0;
|
|
1194
1218
|
this.apps = [];
|
|
1195
1219
|
this.wrote = new Map();
|
|
1220
|
+
this.rewrite = null; // an Esc mid-cut must not end the next turn early
|
|
1196
1221
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1197
1222
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1198
1223
|
const images = await this.attachImages(input);
|
|
@@ -1312,10 +1337,13 @@ export class Agent {
|
|
|
1312
1337
|
let reply;
|
|
1313
1338
|
let streaming = false;
|
|
1314
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();
|
|
1315
1343
|
|
|
1316
1344
|
try {
|
|
1317
1345
|
const opts = {
|
|
1318
|
-
signal: this.abort.signal,
|
|
1346
|
+
signal: AbortSignal.any([this.abort.signal, cut.signal]),
|
|
1319
1347
|
onWait: (text) => this.ui.updateSpinner(text),
|
|
1320
1348
|
};
|
|
1321
1349
|
// Only a real terminal has somewhere to stream into.
|
|
@@ -1344,6 +1372,9 @@ export class Agent {
|
|
|
1344
1372
|
// written now instead of sitting on "working" for ninety seconds.
|
|
1345
1373
|
const writing = new Writing();
|
|
1346
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();
|
|
1347
1378
|
const at = writing.seen(index, name, args);
|
|
1348
1379
|
if (at) this.ui.updateSpinner(at);
|
|
1349
1380
|
};
|
|
@@ -1372,6 +1403,12 @@ export class Agent {
|
|
|
1372
1403
|
this.ui.thinkingEnd();
|
|
1373
1404
|
if (streaming) this.ui.streamEnd();
|
|
1374
1405
|
|
|
1406
|
+
if (this.rewrite && !this.abort.signal.aborted) {
|
|
1407
|
+
this.ui.stopSpinner();
|
|
1408
|
+
if (await this.wrapUp(fixRounds++)) continue;
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1375
1412
|
// The model invented a tool and the provider rejected the request
|
|
1376
1413
|
// outright. Tell it what it did and let it try again.
|
|
1377
1414
|
if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
|
|
@@ -1416,6 +1453,13 @@ export class Agent {
|
|
|
1416
1453
|
// the thinking timer has to be closed out here as well.
|
|
1417
1454
|
this.ui.thinkingEnd();
|
|
1418
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
|
+
}
|
|
1419
1463
|
this.record(reply.usage);
|
|
1420
1464
|
this.ui.step?.();
|
|
1421
1465
|
this.stats.steps++;
|
|
@@ -1544,6 +1588,10 @@ export class Agent {
|
|
|
1544
1588
|
|
|
1545
1589
|
const badArgs = await this.runCalls(reply.toolCalls);
|
|
1546
1590
|
if (this.abort.signal.aborted) return;
|
|
1591
|
+
if (this.rewrite) {
|
|
1592
|
+
if (await this.wrapUp(fixRounds++)) continue;
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1547
1595
|
|
|
1548
1596
|
// Malformed arguments go back to the model, but only so many times.
|
|
1549
1597
|
if (badArgs) {
|
|
@@ -1831,7 +1879,9 @@ export class Agent {
|
|
|
1831
1879
|
// to the model, which can act on it, and not to the screen. A refusal the
|
|
1832
1880
|
// user made, and anything that actually failed, still shows.
|
|
1833
1881
|
if (err instanceof Declined) this.ui.toolFailed('declined');
|
|
1834
|
-
|
|
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}`);
|
|
1835
1885
|
|
|
1836
1886
|
// Declines that keep coming are not decisions, they are a wall.
|
|
1837
1887
|
//
|
|
@@ -1862,6 +1912,54 @@ export class Agent {
|
|
|
1862
1912
|
return err.kind === 'bad_args';
|
|
1863
1913
|
}
|
|
1864
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
|
+
|
|
1865
1963
|
/** The files a whole-file write names, resolved the way the tool will resolve them. */
|
|
1866
1964
|
writesOf(call) {
|
|
1867
1965
|
const a = call.args ?? {};
|
|
@@ -1903,6 +2001,18 @@ export class Agent {
|
|
|
1903
2001
|
});
|
|
1904
2002
|
}
|
|
1905
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
|
+
|
|
1906
2016
|
// A read of "index.html" right after create_app put it in tasker/. The
|
|
1907
2017
|
// model named the file that way, so it looks for it that way: DeepSeek got
|
|
1908
2018
|
// three not-founds and wrote the whole app again. A missing path that
|
|
@@ -1946,6 +2056,19 @@ export class Agent {
|
|
|
1946
2056
|
this.reads.set(key, seen + 1);
|
|
1947
2057
|
}
|
|
1948
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
|
+
|
|
1949
2072
|
// create_app again for the app this turn already made. DeepSeek scaffolded
|
|
1950
2073
|
// tasker with no files, then called create_app twice more — once with no
|
|
1951
2074
|
// folder or name at all — and was refused both times while the empty
|
package/src/tools/index.js
CHANGED
|
@@ -578,6 +578,7 @@ const LENIENT = new Set(['create_app.files']);
|
|
|
578
578
|
const ALIASES = {
|
|
579
579
|
path: ['file', 'filename', 'file_path', 'filepath'],
|
|
580
580
|
content: ['contents', 'text', 'code', 'body'],
|
|
581
|
+
paths: ['files'],
|
|
581
582
|
};
|
|
582
583
|
|
|
583
584
|
function check(name, args) {
|
|
@@ -598,6 +599,10 @@ function check(name, args) {
|
|
|
598
599
|
const other = others.find((o) => args[o] != null && !schema.properties[o]);
|
|
599
600
|
if (other) { args[key] = args[other]; delete args[other]; }
|
|
600
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
|
+
}
|
|
601
606
|
|
|
602
607
|
for (const key of schema.required ?? []) {
|
|
603
608
|
const one = key.endsWith('s') ? key.slice(0, -1) : '';
|