ucode-agent 1.0.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 +240 -0
- package/package.json +54 -0
- package/skills/build-app/SKILL.md +81 -0
- package/skills/code-review/SKILL.md +36 -0
- package/skills/debug/SKILL.md +47 -0
- package/skills/ui-ux/SKILL.md +237 -0
- package/skills/write-tests/SKILL.md +47 -0
- package/src/core/failure.js +70 -0
- package/src/core/history.js +278 -0
- package/src/core/loop.js +1146 -0
- package/src/core/provider.js +740 -0
- package/src/core/skills.js +165 -0
- package/src/core/window.js +127 -0
- package/src/tools/files.js +466 -0
- package/src/tools/index.js +394 -0
- package/src/tools/search.js +192 -0
- package/src/tools/shared.js +343 -0
- package/src/tools/shell.js +553 -0
- package/src/tools/web.js +96 -0
- package/src/ui/markdown.js +64 -0
- package/src/ui/plain.js +325 -0
- package/src/ui/screen.js +1067 -0
- package/src/ui/theme.js +256 -0
- package/ucode.js +118 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell.js — running commands.
|
|
3
|
+
*
|
|
4
|
+
* Most of this file is about the ways a spawned command can go wrong in a way
|
|
5
|
+
* that hurts the agent rather than the task: one that waits for a keyboard it
|
|
6
|
+
* will never get, one that never exits, one that kills the runtime the agent is
|
|
7
|
+
* running on, one whose grandchild holds the pipe open so the close event never
|
|
8
|
+
* arrives. Each is handled explicitly, because each has exactly one symptom from
|
|
9
|
+
* the outside — the agent appears to freeze.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { ToolFailure } from '../core/failure.js';
|
|
17
|
+
import { resolveIn, guard, result, getRoot, MAX_OUTPUT } from './shared.js';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
20
|
+
const MAX_TIMEOUT = 600_000;
|
|
21
|
+
const LIVE_LINES = 200;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Anything that looks like a dev server. These never finish on their own, so
|
|
25
|
+
* waiting for them to is a guaranteed timeout — they are started in the
|
|
26
|
+
* background instead, and watched only until they say they are ready.
|
|
27
|
+
*/
|
|
28
|
+
const LOOKS_LIKE_SERVER =
|
|
29
|
+
/(\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b|\b(?:vite|next dev|next start|nuxt dev|astro dev|webpack serve|svelte-kit dev|serve)\b|\buvicorn\b|\bgunicorn\b|\bflask run\b|\bdjango[\w-]* runserver\b|\bmanage\.py runserver\b|\brails server\b|\bhttp\.server\b|\bhttp-server\b|\blive-server\b)/i;
|
|
30
|
+
|
|
31
|
+
/** A foreground server that was explicitly asked for still gets a short leash. */
|
|
32
|
+
const SERVER_TIMEOUT = 30_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Installs are slow and legitimately so: create-next-app pulls hundreds of
|
|
36
|
+
* packages and a cold cargo build compiles the world. Cutting that off at two
|
|
37
|
+
* minutes leaves a half-written project on disk, which from the outside looks
|
|
38
|
+
* exactly like the agent giving up in the middle. Ten minutes, still bounded.
|
|
39
|
+
*/
|
|
40
|
+
const INSTALL_TIMEOUT = 600_000;
|
|
41
|
+
const LOOKS_LIKE_INSTALL =
|
|
42
|
+
/(\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|ci|create)\b|\bnpx\s+(?:create-|degit\b|shadcn)|\bpip3?\s+install\b|\bpoetry\s+(?:install|add)\b|\bcargo\s+(?:build|install|fetch)\b|\bgo\s+(?:mod\s+download|get)\b|\bbundle\s+install\b|\bcomposer\s+(?:install|require)\b|\bgit\s+clone\b)/i;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Commands that kill a whole class of process rather than one process.
|
|
46
|
+
*
|
|
47
|
+
* `taskkill /IM node.exe /F` takes down the agent itself — it runs on Node —
|
|
48
|
+
* along with the editor, any other servers, and everything else sharing the
|
|
49
|
+
* runtime. It is a real failure mode, not a hypothetical one: a dev server
|
|
50
|
+
* stops answering, the model tries the right PID, misses, and escalates to
|
|
51
|
+
* killing all of Node, at which point it has killed the process it was
|
|
52
|
+
* reporting to and hangs until something times out.
|
|
53
|
+
*
|
|
54
|
+
* Refused with an explanation, so the model can pick the narrow thing instead.
|
|
55
|
+
*/
|
|
56
|
+
const KILLS_EVERYTHING =
|
|
57
|
+
/(\btaskkill\b[^|;&]*\/IM\s+(?:node|node\.exe|cmd|cmd\.exe|powershell|powershell\.exe|pwsh|pwsh\.exe)\b|\b(?:killall|pkill)\s+(?:-\w+\s+)*(?:node|nodejs)\b|\bpkill\b[^|;&]*-f\s+(?:node|npm|pnpm)\b|\bStop-Process\b[^|;&]*-Name\s+["']?node)/i;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// The environment a command runs in
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What every command inherits on top of the user's own environment.
|
|
65
|
+
*
|
|
66
|
+
* Each line removes a way for a command to be slow or to stall:
|
|
67
|
+
*
|
|
68
|
+
* CI scaffolders use their defaults instead of asking,
|
|
69
|
+
* and test runners run once instead of watching forever
|
|
70
|
+
* npm_config_yes npx installs without its "Ok to proceed? (y)"
|
|
71
|
+
* fund / audit npm skips two network round trips on every install
|
|
72
|
+
* update_notifier and the version check on every invocation
|
|
73
|
+
* NEXT_TELEMETRY Next skips its telemetry notice and ping
|
|
74
|
+
* NO_COLOR output comes back as text rather than escape codes,
|
|
75
|
+
* which the model would otherwise have to read past
|
|
76
|
+
*/
|
|
77
|
+
export function childEnv(base = process.env) {
|
|
78
|
+
return {
|
|
79
|
+
...base,
|
|
80
|
+
CI: '1',
|
|
81
|
+
npm_config_yes: 'true',
|
|
82
|
+
npm_config_fund: 'false',
|
|
83
|
+
npm_config_audit: 'false',
|
|
84
|
+
npm_config_update_notifier: 'false',
|
|
85
|
+
NEXT_TELEMETRY_DISABLED: '1',
|
|
86
|
+
NO_COLOR: '1',
|
|
87
|
+
FORCE_COLOR: '0',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Kill a command and everything it started.
|
|
93
|
+
*
|
|
94
|
+
* Killing only the shell leaves the dev server it launched still running, and
|
|
95
|
+
* on Windows a surviving grandchild that inherited our stdio keeps the pipe
|
|
96
|
+
* open — so 'close' never fires and the loop waits forever.
|
|
97
|
+
*/
|
|
98
|
+
export function killTree(pid) {
|
|
99
|
+
if (!pid) return;
|
|
100
|
+
if (process.platform === 'win32') {
|
|
101
|
+
try {
|
|
102
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
103
|
+
} catch { /* best effort */ }
|
|
104
|
+
try { process.kill(pid); } catch { /* already gone */ }
|
|
105
|
+
} else {
|
|
106
|
+
try { process.kill(-pid, 'SIGKILL'); } catch { /* not a group leader */ }
|
|
107
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
108
|
+
try { spawn('pkill', ['-P', String(pid), '-9'], { stdio: 'ignore' }); } catch { /* best effort */ }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Servers
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const LOG_DIR = path.join(os.tmpdir(), 'ucode-logs');
|
|
117
|
+
|
|
118
|
+
/** How long to watch a server for a sign of life before handing back anyway. */
|
|
119
|
+
const READY_WAIT = Number(process.env.UCODE_READY_WAIT_MS) || 45_000;
|
|
120
|
+
|
|
121
|
+
/** Once a URL has been printed, how long to wait for it to also say "ready". */
|
|
122
|
+
const URL_GRACE = 6_000;
|
|
123
|
+
|
|
124
|
+
const POLL = 150;
|
|
125
|
+
|
|
126
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
127
|
+
|
|
128
|
+
const URL_IN_LOG = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]|[\w.-]+\.local)(?::\d{2,5})?(?:\/[^\s'")\]]*)?/i;
|
|
129
|
+
|
|
130
|
+
const READY_IN_LOG =
|
|
131
|
+
/(\bready in\b|✓\s*ready|\bready\b[^\n]*\d+(?:\.\d+)?\s?m?s\b|compiled successfully|compiled client and server|\blistening (?:on|at)\b|server (?:is )?(?:running|started|listening|ready)|\brunning (?:on|at)\b|started server on|application startup complete|development server is running|serving (?:http|at|on)|available on:)/i;
|
|
132
|
+
|
|
133
|
+
/** The URL a person would type: 0.0.0.0 and [::] do not open on Windows. */
|
|
134
|
+
function tidyUrl(url) {
|
|
135
|
+
return url
|
|
136
|
+
.replace(/:\/\/(?:0\.0\.0\.0|\[::1?\])/, '://localhost')
|
|
137
|
+
.replace(/\/$/, '');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readLog(file) {
|
|
141
|
+
try {
|
|
142
|
+
const size = statSync(file).size;
|
|
143
|
+
const text = readFileSync(file, 'utf8');
|
|
144
|
+
// Only the recent part matters, and a chatty server can print a lot.
|
|
145
|
+
return stripAnsi(size > 65_536 ? text.slice(-65_536) : text);
|
|
146
|
+
} catch {
|
|
147
|
+
return '';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function tail(text, keep = 14) {
|
|
152
|
+
const lines = text.trim().split(/\r?\n/).filter(Boolean);
|
|
153
|
+
return lines.length <= keep ? lines : [`… ${lines.length - keep} earlier lines`, ...lines.slice(-keep)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stopHint(pid) {
|
|
157
|
+
return process.platform === 'win32' ? `taskkill /PID ${pid} /T /F` : `kill -- -${pid}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Start something long-running, and come back the moment it is usable.
|
|
162
|
+
*
|
|
163
|
+
* The old way was to start it and return after a fixed half second, which told
|
|
164
|
+
* the model nothing: not whether it had crashed, not which port it chose, not
|
|
165
|
+
* whether it was ready. The model then had to probe, usually too early, and a
|
|
166
|
+
* slow reasoning model spends most of a minute per probe.
|
|
167
|
+
*
|
|
168
|
+
* Instead its output goes to a log file — a file, not a pipe, because nobody
|
|
169
|
+
* will be reading a pipe once this returns and a full pipe stalls the server —
|
|
170
|
+
* and the log is watched until one of three things happens: it prints that it
|
|
171
|
+
* is ready, it exits, or the wait runs out. Whichever comes first is reported
|
|
172
|
+
* with the URL it is actually listening on.
|
|
173
|
+
*/
|
|
174
|
+
function startServer(command, workdir, { env } = {}) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
let log;
|
|
177
|
+
let fd;
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
180
|
+
log = path.join(LOG_DIR, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}.log`);
|
|
181
|
+
fd = openSync(log, 'a');
|
|
182
|
+
} catch (err) {
|
|
183
|
+
reject(new ToolFailure({
|
|
184
|
+
kind: 'log_unwritable',
|
|
185
|
+
attempted: `starting "${command}" in the background`,
|
|
186
|
+
failed: `Could not create a log file in ${LOG_DIR}: ${err.message}`,
|
|
187
|
+
fix: 'Check that the temp directory is writable.',
|
|
188
|
+
cause: err,
|
|
189
|
+
}));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let child;
|
|
194
|
+
try {
|
|
195
|
+
child = spawn(command, {
|
|
196
|
+
cwd: workdir.abs,
|
|
197
|
+
shell: true,
|
|
198
|
+
windowsHide: true,
|
|
199
|
+
detached: true,
|
|
200
|
+
stdio: ['ignore', fd, fd],
|
|
201
|
+
env,
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
reject(new ToolFailure({
|
|
206
|
+
kind: 'spawn_failed',
|
|
207
|
+
attempted: `starting "${command}" in the background`,
|
|
208
|
+
failed: `The shell would not start: ${err.message}`,
|
|
209
|
+
fix: 'Check the command name and that a shell is on PATH.',
|
|
210
|
+
cause: err,
|
|
211
|
+
}));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The child holds its own handle to the log now.
|
|
216
|
+
closeSync(fd);
|
|
217
|
+
child.unref();
|
|
218
|
+
|
|
219
|
+
const started = Date.now();
|
|
220
|
+
let exitCode = null;
|
|
221
|
+
let spawnError = null;
|
|
222
|
+
let urlSeenAt = null;
|
|
223
|
+
let done = false;
|
|
224
|
+
|
|
225
|
+
child.on('exit', (code) => { exitCode = code ?? -1; });
|
|
226
|
+
child.on('error', (err) => { spawnError = err; });
|
|
227
|
+
|
|
228
|
+
const finish = (build) => {
|
|
229
|
+
if (done) return;
|
|
230
|
+
done = true;
|
|
231
|
+
clearInterval(timer);
|
|
232
|
+
resolve(build());
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const describeRun = (lines) => [
|
|
236
|
+
...lines,
|
|
237
|
+
`Command: ${command}`,
|
|
238
|
+
`Directory: ${workdir.show}`,
|
|
239
|
+
`Output log: ${log}`,
|
|
240
|
+
].join('\n');
|
|
241
|
+
|
|
242
|
+
const check = () => {
|
|
243
|
+
const text = readLog(log);
|
|
244
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
245
|
+
|
|
246
|
+
if (spawnError) {
|
|
247
|
+
finish(() => {
|
|
248
|
+
const out = result(
|
|
249
|
+
describeRun([`It could not start: ${spawnError.message}`]),
|
|
250
|
+
'failed to start'
|
|
251
|
+
);
|
|
252
|
+
out.output = tail(text);
|
|
253
|
+
return out;
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (exitCode !== null) {
|
|
259
|
+
finish(() => {
|
|
260
|
+
const out = result(
|
|
261
|
+
describeRun([
|
|
262
|
+
`It exited with code ${exitCode} after ${seconds}s, before it was ready.`,
|
|
263
|
+
'',
|
|
264
|
+
text.trim() || '(it printed nothing)',
|
|
265
|
+
]),
|
|
266
|
+
`exited ${exitCode} before it was ready`
|
|
267
|
+
);
|
|
268
|
+
out.exitCode = exitCode;
|
|
269
|
+
out.output = tail(text);
|
|
270
|
+
return out;
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const url = URL_IN_LOG.exec(text)?.[0]
|
|
276
|
+
?? (/\bport\s+(\d{2,5})\b/i.exec(text) ? `http://localhost:${/\bport\s+(\d{2,5})\b/i.exec(text)[1]}` : null);
|
|
277
|
+
if (url && urlSeenAt === null) urlSeenAt = Date.now();
|
|
278
|
+
const ready = READY_IN_LOG.test(text);
|
|
279
|
+
const graceOver = urlSeenAt !== null && Date.now() - urlSeenAt >= URL_GRACE;
|
|
280
|
+
|
|
281
|
+
if ((ready && url) || graceOver || (ready && Date.now() - started > 1500)) {
|
|
282
|
+
finish(() => {
|
|
283
|
+
const where = url ? tidyUrl(url) : null;
|
|
284
|
+
return result(
|
|
285
|
+
describeRun([
|
|
286
|
+
`Running in the background as PID ${child.pid}, ready after ${seconds}s.`,
|
|
287
|
+
where ? `Open it at ${where}` : 'It did not print a URL; check the log for the port.',
|
|
288
|
+
`Stop it with: ${stopHint(child.pid)}`,
|
|
289
|
+
'',
|
|
290
|
+
'Do not start it again — it is already running.',
|
|
291
|
+
]),
|
|
292
|
+
where ? `ready · ${where} · PID ${child.pid}` : `ready · PID ${child.pid}`
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (Date.now() - started >= READY_WAIT) {
|
|
299
|
+
finish(() => {
|
|
300
|
+
const out = result(
|
|
301
|
+
describeRun([
|
|
302
|
+
`Still starting after ${Math.round(READY_WAIT / 1000)}s — running as PID ${child.pid}, ` +
|
|
303
|
+
'but it has not said it is ready yet.',
|
|
304
|
+
url ? `It mentioned ${tidyUrl(url)}.` : '',
|
|
305
|
+
`Stop it with: ${stopHint(child.pid)}`,
|
|
306
|
+
'',
|
|
307
|
+
text.trim() ? `Latest output:\n${tail(text).join('\n')}` : '(no output yet)',
|
|
308
|
+
].filter((l) => l !== '')),
|
|
309
|
+
`still starting · PID ${child.pid}`
|
|
310
|
+
);
|
|
311
|
+
out.output = tail(text);
|
|
312
|
+
return out;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const timer = setInterval(check, POLL);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// run_command
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
export async function runCommand({ command, cwd, timeout_ms, background }, { onOutput } = {}) {
|
|
326
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
327
|
+
throw new ToolFailure({
|
|
328
|
+
kind: 'bad_args',
|
|
329
|
+
attempted: 'running a command',
|
|
330
|
+
failed: 'The "command" argument was missing or empty.',
|
|
331
|
+
fix: 'Pass the whole command line as one string.',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (KILLS_EVERYTHING.test(command)) {
|
|
336
|
+
throw new ToolFailure({
|
|
337
|
+
kind: 'suicidal_command',
|
|
338
|
+
attempted: `running \`${command.trim().slice(0, 80)}\``,
|
|
339
|
+
failed:
|
|
340
|
+
'That kills every process of its kind, which includes the one running this ' +
|
|
341
|
+
'agent — the session would end in the middle of the task.',
|
|
342
|
+
fix:
|
|
343
|
+
'Kill the single process instead. Start long-running things with background: ' +
|
|
344
|
+
'true, which hands back a PID, then stop that PID by number. For a stuck port, ' +
|
|
345
|
+
'find its owner first: `netstat -ano | findstr :3000` on Windows, ' +
|
|
346
|
+
'`lsof -i :3000` elsewhere.',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const workdir = cwd
|
|
351
|
+
? resolveIn(cwd, 'run_command', 'cwd')
|
|
352
|
+
: { abs: getRoot(), inside: true, show: '.' };
|
|
353
|
+
await guard(workdir, `run a command in ${workdir.abs}`);
|
|
354
|
+
|
|
355
|
+
const env = childEnv();
|
|
356
|
+
const server = LOOKS_LIKE_SERVER.test(command);
|
|
357
|
+
|
|
358
|
+
// A dev server is backgrounded whether or not the model remembered to ask.
|
|
359
|
+
// Only an explicit `background: false` keeps one in the foreground.
|
|
360
|
+
if (background || (server && background !== false)) {
|
|
361
|
+
return startServer(command, workdir, { env });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let timeout = Math.min(Math.max(Number(timeout_ms) || DEFAULT_TIMEOUT, 1000), MAX_TIMEOUT);
|
|
365
|
+
// An explicit timeout_ms is the caller's decision and is left alone. These
|
|
366
|
+
// only adjust the default.
|
|
367
|
+
if (!timeout_ms && LOOKS_LIKE_INSTALL.test(command)) timeout = INSTALL_TIMEOUT;
|
|
368
|
+
if (!timeout_ms && server) timeout = Math.min(timeout, SERVER_TIMEOUT);
|
|
369
|
+
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
let child;
|
|
372
|
+
try {
|
|
373
|
+
child = spawn(command, {
|
|
374
|
+
cwd: workdir.abs,
|
|
375
|
+
shell: true,
|
|
376
|
+
windowsHide: true,
|
|
377
|
+
// No stdin. A command that asks a question gets end-of-input at once
|
|
378
|
+
// and either takes its default or fails in a second — instead of
|
|
379
|
+
// waiting, on an open pipe nobody writes to, until the timeout.
|
|
380
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
381
|
+
env,
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
reject(new ToolFailure({
|
|
385
|
+
kind: 'spawn_failed',
|
|
386
|
+
attempted: `running "${command}"`,
|
|
387
|
+
failed: `The shell would not start: ${err.message}`,
|
|
388
|
+
fix: 'Check the command name and that a shell is on PATH.',
|
|
389
|
+
cause: err,
|
|
390
|
+
}));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let captured = '';
|
|
395
|
+
let timedOut = false;
|
|
396
|
+
let done = false;
|
|
397
|
+
let timer = null;
|
|
398
|
+
let backstop = null;
|
|
399
|
+
|
|
400
|
+
// Output is reported while it happens, so a slow build is something you
|
|
401
|
+
// watch rather than something you sit through. Whole lines only — a
|
|
402
|
+
// partial line waits for its newline — and a \r progress bar collapses to
|
|
403
|
+
// its latest state, since a line meant to overwrite itself should not
|
|
404
|
+
// scroll past a hundred times.
|
|
405
|
+
const live = typeof onOutput === 'function' ? onOutput : null;
|
|
406
|
+
let held = '';
|
|
407
|
+
let livePrinted = 0;
|
|
408
|
+
let liveCapped = false;
|
|
409
|
+
|
|
410
|
+
const take = (chunk) => {
|
|
411
|
+
const text = stripAnsi(chunk.toString());
|
|
412
|
+
// Collect more than will be shown, then trim once at the end.
|
|
413
|
+
if (captured.length < MAX_OUTPUT * 4) captured += text;
|
|
414
|
+
if (!live || liveCapped) return;
|
|
415
|
+
|
|
416
|
+
held += text;
|
|
417
|
+
const parts = held.split(/\r?\n/);
|
|
418
|
+
held = parts.pop();
|
|
419
|
+
if (!parts.length) return;
|
|
420
|
+
|
|
421
|
+
const lines = parts.map((l) => l.split('\r').pop());
|
|
422
|
+
const room = LIVE_LINES - livePrinted;
|
|
423
|
+
if (lines.length > room) {
|
|
424
|
+
lines.length = Math.max(0, room);
|
|
425
|
+
liveCapped = true;
|
|
426
|
+
lines.push('… the rest is in the final output');
|
|
427
|
+
}
|
|
428
|
+
livePrinted += lines.length;
|
|
429
|
+
if (lines.length) live(lines);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
child.stdout?.on('data', take);
|
|
433
|
+
child.stderr?.on('data', take);
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The single place this promise resolves. Both the close event and the
|
|
437
|
+
* post-kill backstop land here, so nothing can leave it pending: a killed
|
|
438
|
+
* shell whose grandchild still holds our stdio never emits 'close', and
|
|
439
|
+
* that used to freeze the entire agent.
|
|
440
|
+
*/
|
|
441
|
+
const finish = (code) => {
|
|
442
|
+
if (done) return;
|
|
443
|
+
done = true;
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
clearTimeout(backstop);
|
|
446
|
+
if (live && held.trim() && !liveCapped) live([held.split('\r').pop()]);
|
|
447
|
+
held = '';
|
|
448
|
+
|
|
449
|
+
const body = captured.trim() || '(no output)';
|
|
450
|
+
// What survives on screen: nothing when it worked and the user already
|
|
451
|
+
// watched it, the tail when it did not.
|
|
452
|
+
const shown = (failed) => (live && !failed && !liveCapped ? [] : tail(body));
|
|
453
|
+
|
|
454
|
+
if (timedOut) {
|
|
455
|
+
let content = `Timed out after ${Math.round(timeout / 1000)}s and was killed.\n\n${body}`;
|
|
456
|
+
if (server || READY_IN_LOG.test(captured)) {
|
|
457
|
+
content +=
|
|
458
|
+
'\n\nThat looks like a server rather than a command that finishes. Run it ' +
|
|
459
|
+
'again without background: false — ucode starts servers in the background ' +
|
|
460
|
+
'and reports the URL as soon as it is ready.';
|
|
461
|
+
} else if (/\?\s*›|\(y\/n\)|\[y\/N\]|press enter|select an option|use arrow keys/i.test(captured)) {
|
|
462
|
+
content +=
|
|
463
|
+
'\n\nIt looks like it stopped to ask a question. Nothing can answer it — pass ' +
|
|
464
|
+
'the non-interactive flag instead (--yes, -y, --defaults, or the option it asked about).';
|
|
465
|
+
}
|
|
466
|
+
const out = result(content, `timed out after ${Math.round(timeout / 1000)}s`);
|
|
467
|
+
out.output = shown(true);
|
|
468
|
+
resolve(out);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const count = captured.trim() ? captured.trim().split(/\r?\n/).length : 0;
|
|
473
|
+
const out = result(
|
|
474
|
+
`exit code: ${code}\n\n${body}`,
|
|
475
|
+
`exit ${code} · ${count} line${count === 1 ? '' : 's'}`
|
|
476
|
+
);
|
|
477
|
+
out.exitCode = code;
|
|
478
|
+
out.output = shown(code !== 0);
|
|
479
|
+
resolve(out);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
timer = setTimeout(() => {
|
|
483
|
+
timedOut = true;
|
|
484
|
+
killTree(child.pid);
|
|
485
|
+
// The tree kill is best effort; if the pipes stay open, force the
|
|
486
|
+
// resolution anyway. The loop has to move on either way.
|
|
487
|
+
backstop = setTimeout(() => finish(null), 2500);
|
|
488
|
+
}, timeout);
|
|
489
|
+
|
|
490
|
+
child.on('error', (err) => {
|
|
491
|
+
if (done) return;
|
|
492
|
+
done = true;
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
clearTimeout(backstop);
|
|
495
|
+
reject(new ToolFailure({
|
|
496
|
+
kind: 'spawn_failed',
|
|
497
|
+
attempted: `running "${command}"`,
|
|
498
|
+
failed: `The command could not run: ${err.message}`,
|
|
499
|
+
fix: 'Check the executable name and your PATH.',
|
|
500
|
+
cause: err,
|
|
501
|
+
}));
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
child.on('close', (code) => finish(code));
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Several commands at once, with a ceiling on how many run together.
|
|
510
|
+
*
|
|
511
|
+
* Install, build and test are independent often enough to be worth it, and
|
|
512
|
+
* three round trips become one.
|
|
513
|
+
*/
|
|
514
|
+
export async function runCommands({ commands, max_parallel = 3 }, opts = {}) {
|
|
515
|
+
if (!Array.isArray(commands) || commands.length === 0) {
|
|
516
|
+
throw new ToolFailure({
|
|
517
|
+
kind: 'bad_args',
|
|
518
|
+
attempted: 'running several commands',
|
|
519
|
+
failed: 'The "commands" argument must be a non-empty array.',
|
|
520
|
+
fix: 'Pass commands as [{ command, cwd?, timeout_ms?, background? }, ...].',
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const width = Math.min(Math.max(Number(max_parallel) || 3, 1), 10);
|
|
525
|
+
const finished = new Array(commands.length);
|
|
526
|
+
let next = 0;
|
|
527
|
+
|
|
528
|
+
const worker = async () => {
|
|
529
|
+
for (;;) {
|
|
530
|
+
const i = next++;
|
|
531
|
+
if (i >= commands.length) return;
|
|
532
|
+
try {
|
|
533
|
+
finished[i] = { ok: await runCommand(commands[i] ?? {}, opts) };
|
|
534
|
+
} catch (err) {
|
|
535
|
+
finished[i] = { err };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
await Promise.all(Array.from({ length: Math.min(width, commands.length) }, worker));
|
|
541
|
+
|
|
542
|
+
const blocks = finished.map((r, i) => {
|
|
543
|
+
const label = `${i + 1}. ${commands[i]?.command ?? '(missing command)'}`;
|
|
544
|
+
if (r.err) return `${label}\nFAILED: ${r.err.failed ?? r.err.message}`;
|
|
545
|
+
return `${label}\n${r.ok.content}`;
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const summary = finished
|
|
549
|
+
.map((r, i) => `${i + 1}. ${r.err ? 'failed' : r.ok.summary}`)
|
|
550
|
+
.join(' · ');
|
|
551
|
+
|
|
552
|
+
return result(blocks.join('\n\n---\n\n'), `${commands.length} commands: ${summary}`);
|
|
553
|
+
}
|
package/src/tools/web.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web.js — looking things up outside the project.
|
|
3
|
+
*
|
|
4
|
+
* Tavily, because it returns prose per result rather than search-engine
|
|
5
|
+
* markup: the difference between something a model can use directly and
|
|
6
|
+
* something it has to parse its way out of first.
|
|
7
|
+
*
|
|
8
|
+
* The key is separate from the model key. Without one the tool explains how to
|
|
9
|
+
* turn it on and tells the model to answer from what it knows and say so —
|
|
10
|
+
* which is a far better outcome than an opaque 401 mid-task.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ToolFailure } from '../core/failure.js';
|
|
14
|
+
import { result } from './shared.js';
|
|
15
|
+
|
|
16
|
+
export async function webSearch({ query, max_results = 5 }) {
|
|
17
|
+
if (typeof query !== 'string' || !query.trim()) {
|
|
18
|
+
throw new ToolFailure({
|
|
19
|
+
kind: 'bad_args',
|
|
20
|
+
attempted: 'searching the web',
|
|
21
|
+
failed: 'The "query" argument was missing or empty.',
|
|
22
|
+
fix: 'Pass what you want to look up, as a string.',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const search = query.trim();
|
|
27
|
+
const key = (process.env.TAVILY_API_KEY ?? '').trim();
|
|
28
|
+
|
|
29
|
+
if (!key) {
|
|
30
|
+
throw new ToolFailure({
|
|
31
|
+
kind: 'no_search_key',
|
|
32
|
+
attempted: `searching the web for "${search}"`,
|
|
33
|
+
failed: 'Web search is switched off — no TAVILY_API_KEY is set.',
|
|
34
|
+
fix:
|
|
35
|
+
'Answer from what you already know and say plainly that you could not check, ' +
|
|
36
|
+
'so the user knows it may be out of date. Then tell them: a free key at ' +
|
|
37
|
+
'https://tavily.com (1000 searches a month, no card) in ~/.ucode/.env as ' +
|
|
38
|
+
'TAVILY_API_KEY=... turns this on.',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const count = Math.min(Math.max(Number(max_results) || 5, 1), 10);
|
|
43
|
+
let response;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
response = await fetch('https://api.tavily.com/search', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'content-type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
api_key: key,
|
|
51
|
+
query: search,
|
|
52
|
+
max_results: count,
|
|
53
|
+
search_depth: 'basic',
|
|
54
|
+
include_answer: true,
|
|
55
|
+
}),
|
|
56
|
+
signal: AbortSignal.timeout(30_000),
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new ToolFailure({
|
|
60
|
+
kind: err.name === 'TimeoutError' ? 'search_timeout' : 'network',
|
|
61
|
+
attempted: `searching the web for "${search}"`,
|
|
62
|
+
failed: err.name === 'TimeoutError'
|
|
63
|
+
? 'The search took more than 30 seconds.'
|
|
64
|
+
: `Could not reach the search API: ${err.message}`,
|
|
65
|
+
fix: 'Try once more. If it keeps failing, answer without it and say the search did not work.',
|
|
66
|
+
cause: err,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const body = await response.text().catch(() => '');
|
|
72
|
+
throw new ToolFailure({
|
|
73
|
+
kind: response.status === 401 ? 'bad_search_key' : 'search_failed',
|
|
74
|
+
attempted: `searching the web for "${search}"`,
|
|
75
|
+
failed: `The search API returned HTTP ${response.status}. ${body.slice(0, 200)}`,
|
|
76
|
+
fix: response.status === 401
|
|
77
|
+
? 'TAVILY_API_KEY is wrong or expired — check it at https://tavily.com'
|
|
78
|
+
: 'Retry once, then answer without it and say the search failed.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const data = await response.json().catch(() => ({}));
|
|
83
|
+
const results = data.results ?? [];
|
|
84
|
+
|
|
85
|
+
if (results.length === 0) return result(`Nothing came back for "${search}".`, 'no results');
|
|
86
|
+
|
|
87
|
+
const body = results
|
|
88
|
+
.map((r, i) =>
|
|
89
|
+
`${i + 1}. ${r.title}\n ${r.url}\n ${(r.content ?? '').replace(/\s+/g, ' ').trim()}`)
|
|
90
|
+
.join('\n\n');
|
|
91
|
+
|
|
92
|
+
return result(
|
|
93
|
+
(data.answer ? `In short: ${data.answer}\n\n` : '') + body,
|
|
94
|
+
`${results.length} result${results.length === 1 ? '' : 's'}`
|
|
95
|
+
);
|
|
96
|
+
}
|