ucode-agent 1.2.0 → 1.3.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 +42 -8
- package/package.json +1 -1
- package/src/core/context.js +151 -0
- package/src/core/loop.js +376 -25
- package/src/core/provider.js +18 -3
- package/src/tools/files.js +173 -16
- package/src/tools/index.js +445 -394
- package/src/tools/shell.js +141 -0
- package/src/ui/plain.js +330 -325
- package/src/ui/screen.js +8 -2
- package/src/ui/theme.js +18 -0
package/src/core/loop.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { readFile, access } from 'node:fs/promises';
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
17
|
|
|
18
18
|
import {
|
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
MODELS, DEFAULT_MODEL, PROVIDER,
|
|
21
21
|
} from './provider.js';
|
|
22
22
|
import {
|
|
23
|
-
tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES,
|
|
23
|
+
tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
|
|
24
24
|
} from '../tools/index.js';
|
|
25
|
+
import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
|
|
25
26
|
import {
|
|
26
27
|
newSession, save, load, list, removeAll, titleFrom,
|
|
27
28
|
} from './history.js';
|
|
@@ -52,7 +53,111 @@ const MAX_ARG_RETRIES = 2;
|
|
|
52
53
|
const MAX_CONTINUATIONS = 3;
|
|
53
54
|
|
|
54
55
|
/** Read-only tools whose result line adds nothing — the user saw the output. */
|
|
55
|
-
const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search']);
|
|
56
|
+
const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
|
|
57
|
+
|
|
58
|
+
/** Tools that draw their own line, so they get no "● Doing X" line of their own. */
|
|
59
|
+
const SILENT = new Set(['update_plan']);
|
|
60
|
+
|
|
61
|
+
/** How many rounds of "the type check found errors, fix them" one turn may take. */
|
|
62
|
+
const MAX_FIX_ROUNDS = 3;
|
|
63
|
+
|
|
64
|
+
/** Files worth checking after they change. */
|
|
65
|
+
const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
|
|
66
|
+
|
|
67
|
+
/** Parallel workers at once, and how many steps each may take. */
|
|
68
|
+
const MAX_WORKERS = 3;
|
|
69
|
+
const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
|
|
70
|
+
|
|
71
|
+
/** Workers build; they do not plan, delegate further, or load skills themselves. */
|
|
72
|
+
const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
|
|
73
|
+
|
|
74
|
+
const planTool = {
|
|
75
|
+
name: 'update_plan',
|
|
76
|
+
description:
|
|
77
|
+
'Keep a short checklist the user can see, for work with three or more steps. ' +
|
|
78
|
+
'Send the whole list every time: at most 6 items, a few words each, with done: true ' +
|
|
79
|
+
'on the finished ones. Update it as items finish. Skip it for small tasks.',
|
|
80
|
+
parameters: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
items: {
|
|
84
|
+
type: 'array',
|
|
85
|
+
description: 'The whole plan, in order. At most 6.',
|
|
86
|
+
items: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
text: { type: 'string', description: 'A few words: "Build the upload box".' },
|
|
90
|
+
done: { type: 'boolean', description: 'True once it is finished.' },
|
|
91
|
+
},
|
|
92
|
+
required: ['text'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
required: ['items'],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const delegateTool = {
|
|
101
|
+
name: 'delegate',
|
|
102
|
+
description:
|
|
103
|
+
'Build independent parts in parallel. Up to 3 workers run at once, each with the ' +
|
|
104
|
+
'same tools as you. Use it when the work splits cleanly into parts that touch ' +
|
|
105
|
+
'different files - e.g. the API route, the upload component and the results view. ' +
|
|
106
|
+
'A worker sees only its instructions, so make them complete: the files it owns, ' +
|
|
107
|
+
'what to build, the exact interfaces (props, types, request and response shapes) ' +
|
|
108
|
+
'it must match, and the design rules. Set up shared files (package.json, design ' +
|
|
109
|
+
'tokens, shared types) yourself first. You get back each worker\'s summary and ' +
|
|
110
|
+
'the files it changed; wire the parts together and check the whole afterwards.',
|
|
111
|
+
parameters: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
tasks: {
|
|
115
|
+
type: 'array',
|
|
116
|
+
description: 'Up to 3 independent pieces of work.',
|
|
117
|
+
items: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
name: { type: 'string', description: 'Two or three words: "api route", "upload ui".' },
|
|
121
|
+
instructions: { type: 'string', description: 'Everything the worker needs to do its part completely.' },
|
|
122
|
+
},
|
|
123
|
+
required: ['name', 'instructions'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
required: ['tasks'],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The instructions a parallel worker starts with. */
|
|
132
|
+
function workerPrompt({ cwd, name, memory, skills }) {
|
|
133
|
+
return [
|
|
134
|
+
`You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
|
|
135
|
+
'',
|
|
136
|
+
`Working directory: ${cwd}`,
|
|
137
|
+
`Platform: ${process.platform}`,
|
|
138
|
+
'',
|
|
139
|
+
'- Do exactly the task you were given. Touch only the files it names, or new files in',
|
|
140
|
+
' the area it owns - other workers are editing the rest of the project right now.',
|
|
141
|
+
'- Read before you edit. read_files for several files, batch_write for several new',
|
|
142
|
+
' files, edit_files for changes across files.',
|
|
143
|
+
'- Nothing has a keyboard: pass non-interactive flags. Do not start dev servers and do',
|
|
144
|
+
' not install packages unless the task says to - say what you need instead.',
|
|
145
|
+
'- Finish what you build: real content, every state handled, no TODOs.',
|
|
146
|
+
'- When done, reply with two or three sentences: what you built, in which files, and',
|
|
147
|
+
' anything the lead has to wire up.',
|
|
148
|
+
...(memory ? ['', '## Project memory', '', memory] : []),
|
|
149
|
+
...(skills ? ['', '## Instructions in force', '', skills] : []),
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The files a writing tool call touches. */
|
|
154
|
+
function pathsOf(call) {
|
|
155
|
+
const a = call.args ?? {};
|
|
156
|
+
if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
|
|
157
|
+
return a.path ? [a.path] : [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const exists = (p) => access(p).then(() => true, () => false);
|
|
56
161
|
|
|
57
162
|
/** Skills reach the model as one extra tool, so bodies load only when wanted. */
|
|
58
163
|
const loadSkillTool = {
|
|
@@ -68,7 +173,7 @@ const loadSkillTool = {
|
|
|
68
173
|
},
|
|
69
174
|
};
|
|
70
175
|
|
|
71
|
-
function systemPrompt({ cwd, skills, mode, check }) {
|
|
176
|
+
function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
72
177
|
const list = catalogue(skills);
|
|
73
178
|
|
|
74
179
|
return [
|
|
@@ -76,6 +181,14 @@ function systemPrompt({ cwd, skills, mode, check }) {
|
|
|
76
181
|
'',
|
|
77
182
|
`Working directory: ${cwd}`,
|
|
78
183
|
`Platform: ${process.platform}`,
|
|
184
|
+
...(memory ? [
|
|
185
|
+
'',
|
|
186
|
+
'## Project memory',
|
|
187
|
+
'',
|
|
188
|
+
'Standing instructions from the user. They outrank your defaults.',
|
|
189
|
+
'',
|
|
190
|
+
memory,
|
|
191
|
+
] : []),
|
|
79
192
|
'',
|
|
80
193
|
'## How to work',
|
|
81
194
|
'',
|
|
@@ -111,7 +224,16 @@ function systemPrompt({ cwd, skills, mode, check }) {
|
|
|
111
224
|
'- Put independent calls in the same message — several greps, a glob and a read.',
|
|
112
225
|
' Read-only calls in one message run at the same time.',
|
|
113
226
|
'- batch_write to lay out several new files at once, multi_edit for several changes',
|
|
114
|
-
' to one file.',
|
|
227
|
+
' to one file, edit_files for a change that spans several files.',
|
|
228
|
+
'- For work with three or more steps, keep a short plan with update_plan - at most',
|
|
229
|
+
' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
|
|
230
|
+
'- When a build splits into parts that touch different files (the API route, the',
|
|
231
|
+
' upload component, the results view), set up the shared files yourself, then hand',
|
|
232
|
+
' the parts to delegate so they are built in parallel.',
|
|
233
|
+
'- A package.json you write starts installing in the background immediately; keep',
|
|
234
|
+
' writing files. Running the install yourself afterwards just waits for that one.',
|
|
235
|
+
'- When you finish, ucode type-checks what you changed and hands you the errors, so',
|
|
236
|
+
' there is no need to run tsc yourself.',
|
|
115
237
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
116
238
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
117
239
|
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
@@ -164,6 +286,13 @@ function systemPrompt({ cwd, skills, mode, check }) {
|
|
|
164
286
|
'',
|
|
165
287
|
list,
|
|
166
288
|
] : []),
|
|
289
|
+
'',
|
|
290
|
+
'## Project map',
|
|
291
|
+
'',
|
|
292
|
+
'Every file in the project at the start of this turn, with the names each code file',
|
|
293
|
+
'exports. Go straight to the files you need instead of searching for them.',
|
|
294
|
+
'',
|
|
295
|
+
map || '(not available)',
|
|
167
296
|
].join('\n');
|
|
168
297
|
}
|
|
169
298
|
|
|
@@ -211,7 +340,14 @@ export class Agent {
|
|
|
211
340
|
*/
|
|
212
341
|
async bootstrap() {
|
|
213
342
|
setRoot(this.cwd);
|
|
214
|
-
|
|
343
|
+
// Parallel workers can ask at the same moment; the questions queue up and
|
|
344
|
+
// are put to the user one at a time, never on top of each other.
|
|
345
|
+
let asking = Promise.resolve();
|
|
346
|
+
setConfirm((request) => {
|
|
347
|
+
const next = asking.then(() => this.ui.confirm(request));
|
|
348
|
+
asking = next.catch(() => {});
|
|
349
|
+
return next;
|
|
350
|
+
});
|
|
215
351
|
this.skills = await loadSkills({ cwd: this.cwd });
|
|
216
352
|
await this.detectCheck();
|
|
217
353
|
}
|
|
@@ -416,6 +552,11 @@ export class Agent {
|
|
|
416
552
|
}
|
|
417
553
|
|
|
418
554
|
this.autoLoad(input);
|
|
555
|
+
// What the model is told about the project, fresh for this turn.
|
|
556
|
+
[this.map, this.memory] = await Promise.all([
|
|
557
|
+
projectMap(this.cwd).catch(() => ''),
|
|
558
|
+
loadMemory(this.cwd).catch(() => ''),
|
|
559
|
+
]);
|
|
419
560
|
await this.persist();
|
|
420
561
|
|
|
421
562
|
this.busy = true;
|
|
@@ -437,7 +578,7 @@ export class Agent {
|
|
|
437
578
|
|
|
438
579
|
/** The tools the model may see, given the mode. */
|
|
439
580
|
toolsNow() {
|
|
440
|
-
const all = [...tools, loadSkillTool];
|
|
581
|
+
const all = [...tools, loadSkillTool, planTool, delegateTool];
|
|
441
582
|
if (this.ui.mode !== 'plan') return all;
|
|
442
583
|
return all.filter((t) => !WRITES.has(t.name));
|
|
443
584
|
}
|
|
@@ -449,8 +590,10 @@ export class Agent {
|
|
|
449
590
|
let continuations = 0;
|
|
450
591
|
let askedToVerify = false;
|
|
451
592
|
let askedToSpeak = false;
|
|
593
|
+
let fixRounds = 0;
|
|
452
594
|
|
|
453
595
|
this.touched = new Set();
|
|
596
|
+
this.sinceCheck = new Set();
|
|
454
597
|
this.ranSomething = false;
|
|
455
598
|
|
|
456
599
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
@@ -459,6 +602,7 @@ export class Agent {
|
|
|
459
602
|
|
|
460
603
|
let reply;
|
|
461
604
|
let streaming = false;
|
|
605
|
+
this.early = new Map();
|
|
462
606
|
|
|
463
607
|
try {
|
|
464
608
|
const opts = {
|
|
@@ -476,6 +620,14 @@ export class Agent {
|
|
|
476
620
|
}
|
|
477
621
|
this.ui.streamDelta(delta);
|
|
478
622
|
};
|
|
623
|
+
// Read-only calls start the moment they are fully written, while the
|
|
624
|
+
// rest of the reply is still arriving. Nothing that writes or runs is
|
|
625
|
+
// started early: a reply that fails halfway must leave no side effects.
|
|
626
|
+
opts.onToolCall = (call) => {
|
|
627
|
+
if (PARALLEL_SAFE.has(call.name) && !call.parseError && !this.early.has(call.id)) {
|
|
628
|
+
this.early.set(call.id, this.execute(call));
|
|
629
|
+
}
|
|
630
|
+
};
|
|
479
631
|
}
|
|
480
632
|
|
|
481
633
|
reply = await ask(
|
|
@@ -487,6 +639,8 @@ export class Agent {
|
|
|
487
639
|
skills: this.skills,
|
|
488
640
|
mode: this.ui.mode,
|
|
489
641
|
check: this.check,
|
|
642
|
+
map: this.map,
|
|
643
|
+
memory: this.memory,
|
|
490
644
|
}),
|
|
491
645
|
},
|
|
492
646
|
...this.working,
|
|
@@ -549,6 +703,24 @@ export class Agent {
|
|
|
549
703
|
continue;
|
|
550
704
|
}
|
|
551
705
|
|
|
706
|
+
// Type-check what changed and hand back the errors, a few rounds at most.
|
|
707
|
+
// A turn that ends on a broken build is the most common way an app
|
|
708
|
+
// gets handed over as done when it is not.
|
|
709
|
+
if (fixRounds < MAX_FIX_ROUNDS) {
|
|
710
|
+
const problems = await this.autoCheck();
|
|
711
|
+
if (problems) {
|
|
712
|
+
fixRounds++;
|
|
713
|
+
if (reply.text) this.push({ role: 'assistant', content: reply.text });
|
|
714
|
+
this.push({
|
|
715
|
+
role: 'user',
|
|
716
|
+
content:
|
|
717
|
+
`ucode checked the files you changed and found errors (round ${fixRounds} of ` +
|
|
718
|
+
`${MAX_FIX_ROUNDS}). Fix all of them, then finish.\n\n${problems}`,
|
|
719
|
+
});
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
552
724
|
// It changed code and never ran anything. Send it back once.
|
|
553
725
|
if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
|
|
554
726
|
askedToVerify = true;
|
|
@@ -656,11 +828,8 @@ export class Agent {
|
|
|
656
828
|
if (this.abort.signal.aborted) return badArgs;
|
|
657
829
|
|
|
658
830
|
const noted = (call) => {
|
|
659
|
-
if (
|
|
660
|
-
this.touched.add(
|
|
661
|
-
}
|
|
662
|
-
if (call.name === 'batch_write') {
|
|
663
|
-
for (const f of call.args?.files ?? []) this.touched.add(f?.path ?? 'a file');
|
|
831
|
+
if (FILE_WRITES.has(call.name)) {
|
|
832
|
+
for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
|
|
664
833
|
}
|
|
665
834
|
if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
|
|
666
835
|
};
|
|
@@ -673,10 +842,7 @@ export class Agent {
|
|
|
673
842
|
this.ui.startSpinner(`${group.length} lookups at once`);
|
|
674
843
|
|
|
675
844
|
const settled = await Promise.all(
|
|
676
|
-
group.map((call) => this.
|
|
677
|
-
(out) => ({ call, out }),
|
|
678
|
-
(err) => ({ call, err })
|
|
679
|
-
))
|
|
845
|
+
group.map((call) => (this.early.get(call.id) ?? this.execute(call)).then((r) => ({ call, ...r })))
|
|
680
846
|
);
|
|
681
847
|
|
|
682
848
|
this.ui.stopSpinner();
|
|
@@ -691,18 +857,14 @@ export class Agent {
|
|
|
691
857
|
if (this.abort.signal.aborted) return badArgs;
|
|
692
858
|
|
|
693
859
|
const label = describe(call.name, call.args);
|
|
694
|
-
this.ui.toolCall(label);
|
|
860
|
+
if (!SILENT.has(call.name)) this.ui.toolCall(label);
|
|
695
861
|
this.ui.startSpinner(label);
|
|
696
862
|
noted(call);
|
|
697
863
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
} catch (err) {
|
|
703
|
-
this.ui.stopSpinner();
|
|
704
|
-
badArgs = this.reportFailure(call, err) || badArgs;
|
|
705
|
-
}
|
|
864
|
+
const { out, err } = await (this.early.get(call.id) ?? this.execute(call));
|
|
865
|
+
this.ui.stopSpinner();
|
|
866
|
+
if (err) badArgs = this.reportFailure(call, err) || badArgs;
|
|
867
|
+
else this.reportResult(call, out);
|
|
706
868
|
}
|
|
707
869
|
}
|
|
708
870
|
|
|
@@ -745,6 +907,8 @@ export class Agent {
|
|
|
745
907
|
}
|
|
746
908
|
|
|
747
909
|
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
910
|
+
if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
|
|
911
|
+
if (call.name === 'delegate') return this.delegate(call.args?.tasks);
|
|
748
912
|
|
|
749
913
|
// Output reaches the screen as the command produces it, so a slow build is
|
|
750
914
|
// something you watch rather than something you sit out in silence.
|
|
@@ -753,6 +917,172 @@ export class Agent {
|
|
|
753
917
|
});
|
|
754
918
|
}
|
|
755
919
|
|
|
920
|
+
/** Run a call and settle to { out } or { err } — never throws. */
|
|
921
|
+
execute(call) {
|
|
922
|
+
return this.dispatch(call).then((out) => ({ out }), (err) => ({ err }));
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
updatePlan(items) {
|
|
926
|
+
const list = (Array.isArray(items) ? items : [])
|
|
927
|
+
.filter((i) => i && String(i.text ?? '').trim())
|
|
928
|
+
.slice(0, 6);
|
|
929
|
+
this.ui.plan(list);
|
|
930
|
+
const done = list.filter((i) => i.done).length;
|
|
931
|
+
return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** File writes from parallel workers take turns, so two never interleave. */
|
|
935
|
+
fileLock(fn) {
|
|
936
|
+
const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
|
|
937
|
+
this.lockChain = run.catch(() => {});
|
|
938
|
+
return run;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Several workers at once, each its own small agent loop with its own
|
|
943
|
+
* conversation, sharing the tools, the project, and whatever skills are
|
|
944
|
+
* already in force. Their lines in the transcript carry their name.
|
|
945
|
+
*/
|
|
946
|
+
async delegate(tasks) {
|
|
947
|
+
const list = (Array.isArray(tasks) ? tasks : [])
|
|
948
|
+
.filter((t) => t && String(t.instructions ?? '').trim())
|
|
949
|
+
.slice(0, MAX_WORKERS);
|
|
950
|
+
if (!list.length) {
|
|
951
|
+
throw new ToolFailure({
|
|
952
|
+
kind: 'bad_args',
|
|
953
|
+
attempted: 'starting workers',
|
|
954
|
+
failed: 'No tasks with instructions were given.',
|
|
955
|
+
fix: 'Pass tasks as [{ name, instructions }, ...], up to 3.',
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const results = await Promise.all(list.map((task, i) => this.runWorker(task, i).catch((err) => ({
|
|
960
|
+
name: task.name || `worker ${i + 1}`,
|
|
961
|
+
summary: `Failed: ${err?.failed ?? err?.message ?? err}`,
|
|
962
|
+
touched: [],
|
|
963
|
+
}))));
|
|
964
|
+
|
|
965
|
+
for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
|
|
966
|
+
|
|
967
|
+
return {
|
|
968
|
+
content: results
|
|
969
|
+
.map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
|
|
970
|
+
.join('\n\n'),
|
|
971
|
+
summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async runWorker(task, index) {
|
|
976
|
+
const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
|
|
977
|
+
const touched = new Set();
|
|
978
|
+
const skills = this.skills
|
|
979
|
+
.filter((s) => this.loaded.has(s.name))
|
|
980
|
+
.map((s) => `--- ${s.name} ---\n${s.body}`)
|
|
981
|
+
.join('\n\n');
|
|
982
|
+
const messages = [
|
|
983
|
+
{ role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills }) },
|
|
984
|
+
{ role: 'user', content: String(task.instructions) },
|
|
985
|
+
];
|
|
986
|
+
const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
|
|
987
|
+
const wanted = process.env.UCODE_WORKER_MODEL;
|
|
988
|
+
const workerModel = wanted && MODELS[wanted] ? wanted : model();
|
|
989
|
+
|
|
990
|
+
for (let step = 0; step < WORKER_STEPS; step++) {
|
|
991
|
+
if (this.abort?.signal.aborted) break;
|
|
992
|
+
const reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
|
|
993
|
+
this.record(reply.usage);
|
|
994
|
+
|
|
995
|
+
if (!reply.toolCalls.length) {
|
|
996
|
+
this.ui.toolResult(`${name} finished`);
|
|
997
|
+
return { name, summary: reply.text?.trim() || 'Finished without a summary.', touched: [...touched] };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
messages.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
|
|
1001
|
+
for (const call of reply.toolCalls) {
|
|
1002
|
+
this.ui.toolCall(`${name} › ${describe(call.name, call.args)}`);
|
|
1003
|
+
if (FILE_WRITES.has(call.name)) for (const p of pathsOf(call)) touched.add(p);
|
|
1004
|
+
const { out, err } = FILE_WRITES.has(call.name)
|
|
1005
|
+
? await this.fileLock(() => this.execute(call))
|
|
1006
|
+
: await this.execute(call);
|
|
1007
|
+
if (err) {
|
|
1008
|
+
if (!(err instanceof ToolFailure)) throw err;
|
|
1009
|
+
this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
|
|
1010
|
+
}
|
|
1011
|
+
messages.push({
|
|
1012
|
+
role: 'tool', toolCallId: call.id, name: call.name,
|
|
1013
|
+
content: err ? err.forModel() : out.content,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return { name, summary: `Stopped after ${WORKER_STEPS} steps without finishing.`, touched: [...touched] };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Check the code files changed since the last check, and return the
|
|
1023
|
+
* errors as text for the model — or null when everything is clean.
|
|
1024
|
+
*
|
|
1025
|
+
* TypeScript projects get one `tsc --noEmit` per project that owns a
|
|
1026
|
+
* changed file (an app scaffolded into a subfolder is its own project).
|
|
1027
|
+
* Plain JavaScript gets a syntax check, Python a compile check. Nothing
|
|
1028
|
+
* runs that is not already installed.
|
|
1029
|
+
*/
|
|
1030
|
+
async autoCheck() {
|
|
1031
|
+
const changed = [...this.sinceCheck].filter((f) => CHECKABLE.test(f));
|
|
1032
|
+
this.sinceCheck.clear();
|
|
1033
|
+
if (!changed.length) return null;
|
|
1034
|
+
|
|
1035
|
+
const root = path.resolve(this.cwd);
|
|
1036
|
+
const tsRoots = new Set();
|
|
1037
|
+
const singles = [];
|
|
1038
|
+
|
|
1039
|
+
for (const rel of changed) {
|
|
1040
|
+
const abs = path.resolve(root, rel);
|
|
1041
|
+
if (!(await exists(abs))) continue;
|
|
1042
|
+
if (/\.py$/i.test(rel)) { singles.push({ abs, rel, command: `python -m py_compile "${abs}"` }); continue; }
|
|
1043
|
+
let dir = path.dirname(abs);
|
|
1044
|
+
let owner = null;
|
|
1045
|
+
while (dir.startsWith(root)) {
|
|
1046
|
+
if (await exists(path.join(dir, 'tsconfig.json'))) { owner = dir; break; }
|
|
1047
|
+
const up = path.dirname(dir);
|
|
1048
|
+
if (up === dir) break;
|
|
1049
|
+
dir = up;
|
|
1050
|
+
}
|
|
1051
|
+
if (owner && (await exists(path.join(owner, 'node_modules', 'typescript')))) tsRoots.add(owner);
|
|
1052
|
+
else if (/\.[cm]?js$/i.test(rel)) singles.push({ abs, rel, command: `node --check "${abs}"` });
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const problems = [];
|
|
1056
|
+
const check = async (label, command, cwd) => {
|
|
1057
|
+
this.ui.toolCall(label);
|
|
1058
|
+
this.ui.startSpinner(label);
|
|
1059
|
+
const { out, err } = await this.execute({
|
|
1060
|
+
id: 'check', name: 'run_command',
|
|
1061
|
+
args: { command, cwd: path.relative(root, cwd) || '.', timeout_ms: 180_000 },
|
|
1062
|
+
});
|
|
1063
|
+
this.ui.stopSpinner();
|
|
1064
|
+
return err ? { exitCode: -1, content: String(err.failed ?? err.message) } : out;
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
for (const dir of tsRoots) {
|
|
1068
|
+
const show = path.relative(root, dir) || '.';
|
|
1069
|
+
const out = await check(`Checking types in ${show}`, 'npx --no-install tsc --noEmit --pretty false', dir);
|
|
1070
|
+
if (out.exitCode === 0) { this.ui.toolResult('types check out'); continue; }
|
|
1071
|
+
const errors = out.content.split('\n').filter((l) => /error TS\d+/.test(l));
|
|
1072
|
+
this.ui.toolFailed(`${errors.length || 'some'} type error${errors.length === 1 ? '' : 's'}`);
|
|
1073
|
+
problems.push(`In ${show} (tsc --noEmit):\n${(errors.length ? errors : out.content.split('\n')).slice(0, 40).join('\n')}`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
for (const f of singles) {
|
|
1077
|
+
const out = await check(`Checking ${f.rel}`, f.command, root);
|
|
1078
|
+
if (out.exitCode === 0) { this.ui.toolResult('ok'); continue; }
|
|
1079
|
+
this.ui.toolFailed('does not compile');
|
|
1080
|
+
problems.push(`${f.rel}:\n${out.content.split('\n').slice(0, 20).join('\n')}`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return problems.length ? problems.join('\n\n') : null;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
756
1086
|
loadSkill(name) {
|
|
757
1087
|
const skill = findSkill(this.skills, name);
|
|
758
1088
|
if (!skill) {
|
|
@@ -847,6 +1177,7 @@ export class Agent {
|
|
|
847
1177
|
return this.cmdSessions(arg);
|
|
848
1178
|
|
|
849
1179
|
case '/new': return this.cmdNew();
|
|
1180
|
+
case '/remember': return this.cmdRemember(arg);
|
|
850
1181
|
case '/skills': return this.cmdSkills();
|
|
851
1182
|
case '/clear': this.showHeader(); return;
|
|
852
1183
|
case '/search': return this.cmdSearch(arg);
|
|
@@ -866,6 +1197,7 @@ export class Agent {
|
|
|
866
1197
|
['/model', 'show the models and switch between them'],
|
|
867
1198
|
['/resume', 'pick up an earlier conversation'],
|
|
868
1199
|
['/new', 'save this one and start fresh'],
|
|
1200
|
+
['/remember <note>', `add a standing note to ${MEMORY_FILE}`],
|
|
869
1201
|
['/skills', 'what ucode knows how to do'],
|
|
870
1202
|
['/search <query>', 'look something up on the web'],
|
|
871
1203
|
['/copy', 'copy the last reply to the clipboard'],
|
|
@@ -1060,6 +1392,25 @@ export class Agent {
|
|
|
1060
1392
|
if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
|
|
1061
1393
|
}
|
|
1062
1394
|
|
|
1395
|
+
/** Add a line to this project's UCODE.md, read at the start of every turn. */
|
|
1396
|
+
async cmdRemember(note) {
|
|
1397
|
+
if (!note) {
|
|
1398
|
+
this.ui.note(`usage: /remember <something ucode should always know here> — saved to ${MEMORY_FILE}`);
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
try {
|
|
1402
|
+
const file = await remember(this.cwd, note);
|
|
1403
|
+
this.ui.note(`remembered · ${path.relative(this.cwd, file) || MEMORY_FILE}`);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
this.ui.error(new Failure({
|
|
1406
|
+
kind: 'memory_unwritable',
|
|
1407
|
+
attempted: `saving to ${MEMORY_FILE}`,
|
|
1408
|
+
failed: err.message,
|
|
1409
|
+
fix: 'Check that this folder is writable.',
|
|
1410
|
+
}), { debug: this.debug });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1063
1414
|
async cmdNew() {
|
|
1064
1415
|
if (this.session.messages.length) {
|
|
1065
1416
|
await this.persist();
|
package/src/core/provider.js
CHANGED
|
@@ -630,6 +630,8 @@ async function streamed(request, opts, id) {
|
|
|
630
630
|
let finishReason = 'stop';
|
|
631
631
|
let usage = null;
|
|
632
632
|
const partial = new Map();
|
|
633
|
+
const handed = new Set();
|
|
634
|
+
let highest = -1;
|
|
633
635
|
|
|
634
636
|
for await (const chunk of stream) {
|
|
635
637
|
if (opts.signal?.aborted) break;
|
|
@@ -656,6 +658,20 @@ async function streamed(request, opts, id) {
|
|
|
656
658
|
// A tool call's name and arguments arrive across several chunks, keyed by
|
|
657
659
|
// index, so they are stitched back together here.
|
|
658
660
|
for (const call of delta.tool_calls ?? []) {
|
|
661
|
+
// Calls arrive one after another, so the first chunk of call N means
|
|
662
|
+
// every call before it is complete. Those are handed over at once, and
|
|
663
|
+
// the caller can start running them while the rest are still being
|
|
664
|
+
// written — the reply streaming and the tools working overlap.
|
|
665
|
+
if (opts.onToolCall && call.index > highest) {
|
|
666
|
+
for (const [index, slot] of partial) {
|
|
667
|
+
if (index < call.index && !handed.has(index)) {
|
|
668
|
+
handed.add(index);
|
|
669
|
+
opts.onToolCall(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
highest = call.index;
|
|
673
|
+
}
|
|
674
|
+
|
|
659
675
|
const slot = partial.get(call.index) ?? { id: '', name: '', args: '' };
|
|
660
676
|
if (call.id) slot.id = call.id;
|
|
661
677
|
if (call.function?.name) slot.name += call.function.name;
|
|
@@ -665,9 +681,8 @@ async function streamed(request, opts, id) {
|
|
|
665
681
|
}
|
|
666
682
|
|
|
667
683
|
const toolCalls = [];
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
toolCalls.push(readCall({ id: slot.id || `call_${n++}`, name: slot.name, raw: slot.args }));
|
|
684
|
+
for (const [index, slot] of partial) {
|
|
685
|
+
toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
|
|
671
686
|
}
|
|
672
687
|
|
|
673
688
|
return {
|