pelulu-cli 1.1.0 → 1.2.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 +8 -5
- package/src/agent/agent-controller.js +6 -1
- package/src/agent/agent-loop.js +169 -197
- package/src/agent/llm-client.js +6 -12
- package/src/core/config.js +1 -1
- package/src/core/http-client.js +285 -0
- package/src/core/job-manager.js +192 -0
- package/src/core/tool-registry.js +53 -2
- package/src/index.js +69 -7
- package/src/mcp/mcp-handler.js +35 -3
- package/src/mcp/mqtt-client.js +184 -13
- package/src/tools/file.js +8 -2
- package/src/tools/git.js +56 -6
- package/src/tools/jobs.js +93 -0
- package/src/tools/network.js +56 -23
- package/src/tools/project.js +38 -4
- package/src/tools/search.js +10 -14
- package/src/tools/shell.js +70 -4
- package/src/tui/ink-app.js +282 -22
- package/src/tui/ink-components.js +41 -18
- package/src/tui/renderer.js +1 -1
- package/docs/AGENT.md +0 -237
- package/specifications/xiaozhi-mqtt-broker.md +0 -46
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP engine for all tools.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: previously every tool re-implemented raw http.request with
|
|
5
|
+
* subtle differences and NONE of them decompressed responses. Real-world
|
|
6
|
+
* servers (Cloudflare, nginx gzip, brotli via CDNs) return compressed bodies,
|
|
7
|
+
* so body-based detection silently failed everywhere. This module centralizes:
|
|
8
|
+
* - transparent gzip / deflate / br decompression
|
|
9
|
+
* - accurate wall-clock timing (duration_ms) for time-based detection
|
|
10
|
+
* - real redirect following with a recorded chain + loop protection
|
|
11
|
+
* - bounded retries with backoff for transient network errors
|
|
12
|
+
* - a global concurrency limiter so batch scans don't self-DoS the target
|
|
13
|
+
* - a hard body-size cap to stay memory-safe on huge responses
|
|
14
|
+
*/
|
|
15
|
+
import https from 'https';
|
|
16
|
+
import http from 'http';
|
|
17
|
+
import zlib from 'zlib';
|
|
18
|
+
import { URL } from 'url';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_UA =
|
|
21
|
+
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
24
|
+
const DEFAULT_MAX_BODY = 2 * 1024 * 1024; // 2 MB
|
|
25
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
26
|
+
const DEFAULT_RETRIES = 1;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Global concurrency limiter (shared across every tool in the process).
|
|
30
|
+
// Keeps aggressive batch scanning from opening hundreds of sockets at once.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
let MAX_CONCURRENCY = 8;
|
|
33
|
+
let active = 0;
|
|
34
|
+
const queue = [];
|
|
35
|
+
|
|
36
|
+
export function setMaxConcurrency(n) {
|
|
37
|
+
MAX_CONCURRENCY = Math.max(1, n | 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function acquire() {
|
|
41
|
+
if (active < MAX_CONCURRENCY) {
|
|
42
|
+
active++;
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve) => queue.push(resolve));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function release() {
|
|
49
|
+
active--;
|
|
50
|
+
const next = queue.shift();
|
|
51
|
+
if (next) {
|
|
52
|
+
active++;
|
|
53
|
+
next();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decompress(buffer, encoding) {
|
|
58
|
+
if (!buffer || buffer.length === 0) return '';
|
|
59
|
+
try {
|
|
60
|
+
const enc = (encoding || '').toLowerCase();
|
|
61
|
+
if (enc.includes('br')) return zlib.brotliDecompressSync(buffer).toString('utf8');
|
|
62
|
+
if (enc.includes('gzip')) return zlib.gunzipSync(buffer).toString('utf8');
|
|
63
|
+
if (enc.includes('deflate')) {
|
|
64
|
+
try {
|
|
65
|
+
return zlib.inflateSync(buffer).toString('utf8');
|
|
66
|
+
} catch {
|
|
67
|
+
return zlib.inflateRawSync(buffer).toString('utf8');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Corrupt / partial compressed stream — fall back to raw bytes.
|
|
72
|
+
}
|
|
73
|
+
return buffer.toString('utf8');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeHeaders(raw) {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const [k, v] of Object.entries(raw || {})) {
|
|
79
|
+
out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Return set-cookie always as an array (Node gives array or string). */
|
|
85
|
+
function rawSetCookies(headers) {
|
|
86
|
+
const sc = headers['set-cookie'];
|
|
87
|
+
if (!sc) return [];
|
|
88
|
+
return Array.isArray(sc) ? sc : [sc];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function singleRequest(rawUrl, opts) {
|
|
92
|
+
const {
|
|
93
|
+
method = 'GET',
|
|
94
|
+
headers = {},
|
|
95
|
+
body = null,
|
|
96
|
+
timeout = DEFAULT_TIMEOUT,
|
|
97
|
+
maxBody = DEFAULT_MAX_BODY,
|
|
98
|
+
} = opts;
|
|
99
|
+
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = new URL(rawUrl);
|
|
104
|
+
} catch {
|
|
105
|
+
return reject(new Error(`Invalid URL: ${rawUrl}`));
|
|
106
|
+
}
|
|
107
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
108
|
+
|
|
109
|
+
const outHeaders = {
|
|
110
|
+
'User-Agent': DEFAULT_UA,
|
|
111
|
+
'Accept': '*/*',
|
|
112
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
113
|
+
'Connection': 'close',
|
|
114
|
+
...headers,
|
|
115
|
+
};
|
|
116
|
+
if (body && !outHeaders['Content-Length'] && !outHeaders['content-length']) {
|
|
117
|
+
outHeaders['Content-Length'] = Buffer.byteLength(body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const reqOpts = {
|
|
121
|
+
protocol: parsed.protocol,
|
|
122
|
+
hostname: parsed.hostname,
|
|
123
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
124
|
+
path: parsed.pathname + parsed.search,
|
|
125
|
+
method: method.toUpperCase(),
|
|
126
|
+
headers: outHeaders,
|
|
127
|
+
timeout,
|
|
128
|
+
rejectUnauthorized: false,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const start = Date.now();
|
|
132
|
+
const req = lib.request(reqOpts, (res) => {
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
let truncated = false;
|
|
136
|
+
res.on('data', (c) => {
|
|
137
|
+
size += c.length;
|
|
138
|
+
if (size <= maxBody) chunks.push(c);
|
|
139
|
+
else truncated = true;
|
|
140
|
+
});
|
|
141
|
+
res.on('end', () => {
|
|
142
|
+
const headersLower = normalizeHeaders(res.headers);
|
|
143
|
+
const buf = Buffer.concat(chunks);
|
|
144
|
+
const decoded = decompress(buf, headersLower['content-encoding']);
|
|
145
|
+
resolve({
|
|
146
|
+
status: res.statusCode,
|
|
147
|
+
headers: headersLower,
|
|
148
|
+
setCookies: rawSetCookies(headersLower),
|
|
149
|
+
body: decoded,
|
|
150
|
+
bytes: size,
|
|
151
|
+
truncated,
|
|
152
|
+
duration_ms: Date.now() - start,
|
|
153
|
+
url: rawUrl,
|
|
154
|
+
finalUrl: rawUrl,
|
|
155
|
+
location: headersLower['location'] || null,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
res.on('error', reject);
|
|
159
|
+
});
|
|
160
|
+
req.on('error', reject);
|
|
161
|
+
req.on('timeout', () => {
|
|
162
|
+
req.destroy();
|
|
163
|
+
reject(new Error(`Timeout after ${timeout}ms`));
|
|
164
|
+
});
|
|
165
|
+
if (body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(reqOpts.method)) req.write(body);
|
|
166
|
+
req.end();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Perform an HTTP request with retries, redirect following and decompression.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} url
|
|
174
|
+
* @param {object} [options]
|
|
175
|
+
* @param {string} [options.method='GET']
|
|
176
|
+
* @param {object} [options.headers]
|
|
177
|
+
* @param {string} [options.body]
|
|
178
|
+
* @param {number} [options.timeout=15000]
|
|
179
|
+
* @param {boolean} [options.followRedirects=false]
|
|
180
|
+
* @param {number} [options.maxRedirects=5]
|
|
181
|
+
* @param {number} [options.retries=1]
|
|
182
|
+
* @param {number} [options.maxBody]
|
|
183
|
+
* @returns {Promise<object>} normalized response with duration_ms, redirectChain, etc.
|
|
184
|
+
*/
|
|
185
|
+
export async function request(url, options = {}) {
|
|
186
|
+
const {
|
|
187
|
+
followRedirects = false,
|
|
188
|
+
maxRedirects = DEFAULT_MAX_REDIRECTS,
|
|
189
|
+
retries = DEFAULT_RETRIES,
|
|
190
|
+
} = options;
|
|
191
|
+
|
|
192
|
+
await acquire();
|
|
193
|
+
try {
|
|
194
|
+
let attempt = 0;
|
|
195
|
+
let lastErr;
|
|
196
|
+
let res;
|
|
197
|
+
// Retry loop for transient failures (ECONNRESET / timeouts / etc).
|
|
198
|
+
while (attempt <= retries) {
|
|
199
|
+
try {
|
|
200
|
+
res = await singleRequest(url, options);
|
|
201
|
+
break;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
lastErr = err;
|
|
204
|
+
attempt++;
|
|
205
|
+
if (attempt > retries) throw lastErr;
|
|
206
|
+
await new Promise((r) => setTimeout(r, 150 * attempt));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const redirectChain = [];
|
|
211
|
+
const visited = new Set([url]);
|
|
212
|
+
let current = res;
|
|
213
|
+
let hops = 0;
|
|
214
|
+
let currentUrl = url;
|
|
215
|
+
while (
|
|
216
|
+
followRedirects &&
|
|
217
|
+
current.status >= 300 &&
|
|
218
|
+
current.status < 400 &&
|
|
219
|
+
current.location &&
|
|
220
|
+
hops < maxRedirects
|
|
221
|
+
) {
|
|
222
|
+
let nextUrl;
|
|
223
|
+
try {
|
|
224
|
+
nextUrl = new URL(current.location, currentUrl).href;
|
|
225
|
+
} catch {
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
redirectChain.push({ status: current.status, from: currentUrl, to: nextUrl });
|
|
229
|
+
if (visited.has(nextUrl)) break; // loop guard: already fetched this absolute URL
|
|
230
|
+
visited.add(nextUrl);
|
|
231
|
+
// Per RFC, 303 (and commonly 301/302) downgrade to GET without a body.
|
|
232
|
+
const nextMethod = [301, 302, 303].includes(current.status) ? 'GET' : options.method || 'GET';
|
|
233
|
+
const nextBody = nextMethod === 'GET' ? null : options.body;
|
|
234
|
+
current = await singleRequest(nextUrl, { ...options, method: nextMethod, body: nextBody });
|
|
235
|
+
currentUrl = nextUrl;
|
|
236
|
+
hops++;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
current.redirectChain = redirectChain;
|
|
240
|
+
current.finalUrl = currentUrl;
|
|
241
|
+
current.requestedUrl = url;
|
|
242
|
+
return current;
|
|
243
|
+
} finally {
|
|
244
|
+
release();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Convenience GET. */
|
|
249
|
+
export function get(url, options = {}) {
|
|
250
|
+
return request(url, { ...options, method: 'GET' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Run an async mapper over items with bounded parallelism.
|
|
255
|
+
* (The global limiter also applies, but this keeps result ordering + batching.)
|
|
256
|
+
*/
|
|
257
|
+
export async function mapLimit(items, limit, mapper) {
|
|
258
|
+
const results = new Array(items.length);
|
|
259
|
+
let idx = 0;
|
|
260
|
+
const workers = new Array(Math.min(limit, items.length || 1)).fill(0).map(async () => {
|
|
261
|
+
while (idx < items.length) {
|
|
262
|
+
const i = idx++;
|
|
263
|
+
try {
|
|
264
|
+
results[i] = await mapper(items[i], i);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
results[i] = { __error: err?.message || String(err) };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
await Promise.all(workers);
|
|
271
|
+
return results;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Parse a "key:value|key:value" header string into an object. */
|
|
275
|
+
export function parseHeaderString(headerStr) {
|
|
276
|
+
const headers = {};
|
|
277
|
+
if (!headerStr) return headers;
|
|
278
|
+
for (const pair of headerStr.split('|')) {
|
|
279
|
+
const idx = pair.indexOf(':');
|
|
280
|
+
if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
281
|
+
}
|
|
282
|
+
return headers;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export default { request, get, mapLimit, setMaxConcurrency, parseHeaderString, DEFAULT_UA };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JobManager — universal async job layer for ALL tools.
|
|
3
|
+
*
|
|
4
|
+
* WHY: XiaoZhi aborts any MCP tool call that runs longer than ~30s and then
|
|
5
|
+
* assumes the tool "timed out" — even though it is still working. That is the
|
|
6
|
+
* root cause of the "system timeout padahal berjalan" bug. Instead of making
|
|
7
|
+
* every one of the tools background-aware by hand, we wrap the tool dispatch
|
|
8
|
+
* boundary in a single job layer:
|
|
9
|
+
*
|
|
10
|
+
* - Fast actions (finish within `graceMs`) return their result inline, exactly
|
|
11
|
+
* as before — no behaviour change for reads/config/etc.
|
|
12
|
+
* - Slow actions keep running in the BACKGROUND and immediately return a
|
|
13
|
+
* job handle. The AI (or the user) then polls the `jobs` tool for progress
|
|
14
|
+
* and the final result, so the agent never stalls or hallucinates a failure.
|
|
15
|
+
*
|
|
16
|
+
* Every job streams lifecycle events on the bus (job:started / job:progress /
|
|
17
|
+
* job:done) so the TUI can show continuous, inline feedback for ANY tool.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from 'crypto';
|
|
20
|
+
import { bus } from './event-bus.js';
|
|
21
|
+
import { debug } from './logger.js';
|
|
22
|
+
|
|
23
|
+
const MAX_JOBS = 40; // ring-buffer cap so memory never grows unbounded
|
|
24
|
+
const DEFAULT_GRACE_MS = 8000; // < XiaoZhi's ~30s timeout, with wide margin
|
|
25
|
+
|
|
26
|
+
class JobManager {
|
|
27
|
+
#jobs = new Map(); // id -> job
|
|
28
|
+
#order = []; // insertion order for pruning + "latest"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `executor(job)` under a job. Resolves as soon as we know whether the
|
|
32
|
+
* work finished quickly or had to be backgrounded.
|
|
33
|
+
*
|
|
34
|
+
* @returns {Promise<{done:boolean, job:object, result?:any, error?:Error}>}
|
|
35
|
+
* done=true -> finished within the grace window (result/error populated)
|
|
36
|
+
* done=false -> still running in the background (poll via the `jobs` tool)
|
|
37
|
+
*/
|
|
38
|
+
async dispatch({ tool, action, label } = {}, executor, { graceMs = DEFAULT_GRACE_MS } = {}) {
|
|
39
|
+
const job = this.#create({ tool, action, label });
|
|
40
|
+
|
|
41
|
+
// Kick off the actual work. We attach terminal handlers up-front so the job
|
|
42
|
+
// is always finalized regardless of whether we returned inline or backgrounded.
|
|
43
|
+
const work = Promise.resolve()
|
|
44
|
+
.then(() => executor(job))
|
|
45
|
+
.then((result) => { this.#finish(job, result); return { kind: 'result', result }; })
|
|
46
|
+
.catch((error) => { this.#fail(job, error); return { kind: 'error', error }; });
|
|
47
|
+
|
|
48
|
+
const raced = await Promise.race([
|
|
49
|
+
work,
|
|
50
|
+
new Promise((res) => setTimeout(() => res({ kind: 'timeout' }), graceMs)),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
if (raced.kind === 'result') return { done: true, job, result: raced.result };
|
|
54
|
+
if (raced.kind === 'error') return { done: true, job, error: raced.error };
|
|
55
|
+
|
|
56
|
+
// Timed out the grace window -> run in background, let the AI poll.
|
|
57
|
+
job.backgrounded = true;
|
|
58
|
+
debug('job', `${tool}.${action || ''} backgrounded as ${job.id}`);
|
|
59
|
+
bus.emit('job:backgrounded', this.snapshot(job));
|
|
60
|
+
return { done: false, job };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Append a human-readable progress line to a running job (streamed to TUI). */
|
|
64
|
+
progress(jobId, message, extra = {}) {
|
|
65
|
+
const job = this.#jobs.get(jobId);
|
|
66
|
+
if (!job || job.status !== 'running') return;
|
|
67
|
+
const entry = { at: Date.now(), message, ...extra };
|
|
68
|
+
job.progress.push(entry);
|
|
69
|
+
if (job.progress.length > 100) job.progress.shift();
|
|
70
|
+
bus.emit('job:progress', { id: job.id, tool: job.tool, action: job.action, message, ...extra });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get(id) { return this.#jobs.get(id) || null; }
|
|
74
|
+
|
|
75
|
+
/** Most recent job (running preferred), for "status" with no explicit id. */
|
|
76
|
+
latest() {
|
|
77
|
+
for (let i = this.#order.length - 1; i >= 0; i--) {
|
|
78
|
+
const job = this.#jobs.get(this.#order[i]);
|
|
79
|
+
if (job && job.status === 'running') return job;
|
|
80
|
+
}
|
|
81
|
+
const lastId = this.#order[this.#order.length - 1];
|
|
82
|
+
return lastId ? this.#jobs.get(lastId) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
list() { return this.#order.map((id) => this.#jobs.get(id)).filter(Boolean); }
|
|
86
|
+
|
|
87
|
+
running() { return this.list().filter((j) => j.status === 'running'); }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Cooperative cancel: flag the job so cancel-aware executors can bail out.
|
|
91
|
+
* Non-cooperative work keeps running but its result is discarded.
|
|
92
|
+
*/
|
|
93
|
+
cancel(id) {
|
|
94
|
+
const job = this.#jobs.get(id);
|
|
95
|
+
if (!job || job.status !== 'running') return false;
|
|
96
|
+
job.cancelRequested = true;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Wait up to `timeoutMs` for a job to leave the running state. */
|
|
101
|
+
async wait(id, timeoutMs = 20000) {
|
|
102
|
+
const job = this.#jobs.get(id);
|
|
103
|
+
if (!job) return null;
|
|
104
|
+
if (job.status !== 'running') return job;
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const done = () => { clearInterval(poll); clearTimeout(timer); bus.off('job:done', onDone); resolve(job); };
|
|
107
|
+
const onDone = (snap) => { if (snap.id === id) done(); };
|
|
108
|
+
const poll = setInterval(() => { if (job.status !== 'running') done(); }, 200);
|
|
109
|
+
const timer = setTimeout(() => { clearInterval(poll); bus.off('job:done', onDone); resolve(job); }, timeoutMs);
|
|
110
|
+
bus.on('job:done', onDone);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Plain, serializable view of a job for tool results / events. */
|
|
115
|
+
snapshot(job) {
|
|
116
|
+
if (!job) return null;
|
|
117
|
+
const now = job.finishedAt || Date.now();
|
|
118
|
+
return {
|
|
119
|
+
id: job.id,
|
|
120
|
+
tool: job.tool,
|
|
121
|
+
action: job.action,
|
|
122
|
+
label: job.label,
|
|
123
|
+
status: job.status,
|
|
124
|
+
backgrounded: !!job.backgrounded,
|
|
125
|
+
elapsed_s: Math.round((now - job.startedAt) / 1000),
|
|
126
|
+
progress: job.progress.slice(-5).map((p) => p.message),
|
|
127
|
+
error: job.error || undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── internals ───────────────────────────────────────────
|
|
132
|
+
#create({ tool, action, label }) {
|
|
133
|
+
const job = {
|
|
134
|
+
id: `job_${randomUUID().slice(0, 8)}`,
|
|
135
|
+
tool, action,
|
|
136
|
+
label: label || `${tool}${action ? `.${action}` : ''}`,
|
|
137
|
+
status: 'running',
|
|
138
|
+
startedAt: Date.now(),
|
|
139
|
+
finishedAt: null,
|
|
140
|
+
progress: [],
|
|
141
|
+
result: undefined,
|
|
142
|
+
error: null,
|
|
143
|
+
backgrounded: false,
|
|
144
|
+
cancelRequested: false,
|
|
145
|
+
};
|
|
146
|
+
this.#jobs.set(job.id, job);
|
|
147
|
+
this.#order.push(job.id);
|
|
148
|
+
this.#prune();
|
|
149
|
+
bus.emit('job:started', this.snapshot(job));
|
|
150
|
+
return job;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#finish(job, result) {
|
|
154
|
+
if (job.status !== 'running') return;
|
|
155
|
+
job.status = job.cancelRequested ? 'cancelled' : 'done';
|
|
156
|
+
job.finishedAt = Date.now();
|
|
157
|
+
job.result = this.#normalizeResult(result);
|
|
158
|
+
bus.emit('job:done', { ...this.snapshot(job), result: job.result });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#fail(job, error) {
|
|
162
|
+
if (job.status !== 'running') return;
|
|
163
|
+
job.status = 'error';
|
|
164
|
+
job.finishedAt = Date.now();
|
|
165
|
+
job.error = error?.message || String(error);
|
|
166
|
+
bus.emit('job:done', { ...this.snapshot(job), error: job.error });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Tool results come back in MCP shape ({ isError, content:[{text}] }). Unwrap
|
|
171
|
+
* to the raw payload so polling the `jobs` tool returns clean data.
|
|
172
|
+
*/
|
|
173
|
+
#normalizeResult(result) {
|
|
174
|
+
if (result && Array.isArray(result.content) && result.content[0]?.type === 'text') {
|
|
175
|
+
const text = result.content[0].text;
|
|
176
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#prune() {
|
|
182
|
+
while (this.#order.length > MAX_JOBS) {
|
|
183
|
+
const oldest = this.#order.shift();
|
|
184
|
+
const job = this.#jobs.get(oldest);
|
|
185
|
+
// Never evict a job that is still running.
|
|
186
|
+
if (job && job.status === 'running') { this.#order.push(oldest); break; }
|
|
187
|
+
this.#jobs.delete(oldest);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const jobManager = new JobManager();
|
|
@@ -51,6 +51,26 @@ export class ToolRegistry {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
toMcpTools() {
|
|
54
|
+
// Trimmed descriptions to stay under XiaoZhi broker 8KB limit
|
|
55
|
+
const desc = {
|
|
56
|
+
agent: 'Agent: spawn, list, status',
|
|
57
|
+
ai: 'Code analysis, summarize, diff',
|
|
58
|
+
config: 'Config: get, set, list, reset',
|
|
59
|
+
diff: 'File comparison: compare, patch',
|
|
60
|
+
env: 'Env variables: get, set, list',
|
|
61
|
+
file: 'File ops: read, write, edit, delete, copy',
|
|
62
|
+
git: 'Git: init, clone, status, diff, commit, push',
|
|
63
|
+
history: 'Tool history: list, clear, stats',
|
|
64
|
+
jobs: 'Poll background jobs: list, status, wait, result, cancel',
|
|
65
|
+
network: 'Network: fetch, download, ping',
|
|
66
|
+
process: 'Process: list, info, kill, top',
|
|
67
|
+
project: 'Project: init, build, test, lint',
|
|
68
|
+
search: 'Search: grep, find, web fetch',
|
|
69
|
+
shell: 'Shell: exec, background, ps, kill',
|
|
70
|
+
snippet: 'Snippets: save, load, list, delete',
|
|
71
|
+
template: 'Templates: list, create, info',
|
|
72
|
+
watch: 'File watch: start, stop, status',
|
|
73
|
+
};
|
|
54
74
|
return this.all().map(t => {
|
|
55
75
|
const schema = t.inputSchema || { type: 'object', properties: {} };
|
|
56
76
|
const compact = { type: 'object', properties: {} };
|
|
@@ -62,7 +82,18 @@ export class ToolRegistry {
|
|
|
62
82
|
compact.properties[k] = prop;
|
|
63
83
|
}
|
|
64
84
|
}
|
|
65
|
-
|
|
85
|
+
// Include per-action required fields in the description so the AI
|
|
86
|
+
// knows exactly which params each action needs (MCP schema only
|
|
87
|
+
// supports a flat required array, not conditional per-action).
|
|
88
|
+
let description = desc[t.name] || t.description;
|
|
89
|
+
if (t.actions?.length) {
|
|
90
|
+
const actionReqs = t.actions
|
|
91
|
+
.filter(a => a.required?.length)
|
|
92
|
+
.map(a => `${a.name}→${a.required.join(',')}`)
|
|
93
|
+
.join('; ');
|
|
94
|
+
if (actionReqs) description += ` [required: ${actionReqs}]`;
|
|
95
|
+
}
|
|
96
|
+
return { name: t.name, description, inputSchema: compact };
|
|
66
97
|
});
|
|
67
98
|
}
|
|
68
99
|
|
|
@@ -70,13 +101,33 @@ export class ToolRegistry {
|
|
|
70
101
|
const tool = this.#tools.get(name);
|
|
71
102
|
if (!tool) return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
72
103
|
try {
|
|
73
|
-
const result = await tool.handler(args);
|
|
104
|
+
const result = await tool.handler(args, this._toolsRef());
|
|
74
105
|
return { isError: false, content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
75
106
|
} catch (e) {
|
|
76
107
|
return { isError: true, content: [{ type: 'text', text: e.message }] };
|
|
77
108
|
}
|
|
78
109
|
}
|
|
79
110
|
|
|
111
|
+
/** Call a tool and return parsed result (for internal tool-to-tool calls) */
|
|
112
|
+
async callParsed(name, args = {}) {
|
|
113
|
+
const tool = this.#tools.get(name);
|
|
114
|
+
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
115
|
+
return await tool.handler(args, this._toolsRef());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The reference handed to every tool handler. Beyond `call`, it exposes tool
|
|
120
|
+
* discovery (`has` / `names`) so orchestrator tools can adapt dynamically to
|
|
121
|
+
* whatever tools are actually loaded instead of hardcoding a fixed pipeline.
|
|
122
|
+
*/
|
|
123
|
+
_toolsRef() {
|
|
124
|
+
return {
|
|
125
|
+
call: (toolName, toolArgs) => this.callParsed(toolName, toolArgs),
|
|
126
|
+
has: (toolName) => this.#tools.has(toolName),
|
|
127
|
+
names: () => [...this.#tools.keys()],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
80
131
|
async shutdown() {
|
|
81
132
|
for (const tool of this.#tools.values()) {
|
|
82
133
|
if (tool.shutdown) await tool.shutdown();
|
package/src/index.js
CHANGED
|
@@ -13,10 +13,12 @@ import { dirname, join } from 'path';
|
|
|
13
13
|
import { readFile } from 'fs/promises';
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
15
|
import chalk from 'chalk';
|
|
16
|
-
import {
|
|
16
|
+
import { existsSync } from 'fs';
|
|
17
|
+
import { loadConfig, saveConfig, expand } from './core/config.js';
|
|
17
18
|
import { log, setDebug, debug, setInkMode, initFileLog, flushLogs, getLogFile, writeRawToLog } from './core/logger.js';
|
|
18
19
|
import { bus } from './core/event-bus.js';
|
|
19
20
|
import { ToolRegistry } from './core/tool-registry.js';
|
|
21
|
+
import { jobManager } from './core/job-manager.js';
|
|
20
22
|
import { Sandbox } from './core/sandbox.js';
|
|
21
23
|
import { SessionState } from './core/session.js';
|
|
22
24
|
import { Stats } from './core/stats.js';
|
|
@@ -177,24 +179,84 @@ async function main() {
|
|
|
177
179
|
|
|
178
180
|
// 10. Register MCP tool handler (for XiaoZhi direct tool calls)
|
|
179
181
|
// Agent calls are auto-approved — XiaoZhi controls the flow, no y/N prompts
|
|
182
|
+
// File tool actions that mutate the filesystem — tracked + surfaced in chat
|
|
183
|
+
const FILE_MUTATIONS = ['write', 'edit', 'delete', 'mkdir', 'copy', 'move'];
|
|
184
|
+
|
|
180
185
|
mqtt.registerToolHandler(
|
|
181
186
|
async (name, args) => {
|
|
182
187
|
sandbox.validate(name, args);
|
|
183
188
|
|
|
184
189
|
thinking.set('tool_call');
|
|
185
190
|
const start = Date.now();
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
|
|
192
|
+
// Capture pre-state so we can report created vs modified accurately
|
|
193
|
+
const trackedPath = args?.to || args?.path;
|
|
194
|
+
let preExisted = false;
|
|
195
|
+
if (name === 'file' && FILE_MUTATIONS.includes(args?.action) && trackedPath) {
|
|
196
|
+
try { preExisted = existsSync(expand(trackedPath)); } catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Post-processing shared by the inline and background completion paths:
|
|
200
|
+
// record stats, track file mutations, and surface the result in the TUI.
|
|
201
|
+
const finalize = (result) => {
|
|
188
202
|
stats.record(name, args.action, !result.isError, Date.now() - start);
|
|
189
203
|
session.addToolCall(name, args, result);
|
|
190
204
|
|
|
191
|
-
|
|
192
|
-
|
|
205
|
+
// Track file changes and broadcast them so the TUI can show tracking in chat
|
|
206
|
+
if (name === 'file' && !result.isError && FILE_MUTATIONS.includes(args.action) && trackedPath) {
|
|
207
|
+
const change =
|
|
208
|
+
args.action === 'delete' ? 'deleted'
|
|
209
|
+
: (args.action === 'edit' || preExisted) ? 'modified'
|
|
210
|
+
: 'created';
|
|
211
|
+
fileTracker.track(trackedPath, change);
|
|
212
|
+
bus.emit('files:changed', { path: trackedPath, change, changes: fileTracker.getChanges() });
|
|
213
|
+
|
|
214
|
+
if (args.action === 'write' || args.action === 'edit') {
|
|
215
|
+
autoFormat(trackedPath).catch(() => {});
|
|
216
|
+
}
|
|
193
217
|
}
|
|
194
218
|
|
|
195
|
-
thinking.set('idle');
|
|
196
219
|
bus.emit('tool:called', { name, result, args });
|
|
197
|
-
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Route EVERY tool action through the job layer. Fast actions resolve
|
|
224
|
+
// inline (unchanged UX); slow ones background themselves and return a
|
|
225
|
+
// pollable job handle so XiaoZhi never assumes a timeout while the tool
|
|
226
|
+
// is still working. The AI polls progress/results with the `jobs` tool.
|
|
227
|
+
const dispatched = await jobManager.dispatch(
|
|
228
|
+
{ tool: name, action: args.action, label: `${name}${args.action ? `.${args.action}` : ''}` },
|
|
229
|
+
() => registry.call(name, args),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (dispatched.done) {
|
|
233
|
+
if (dispatched.error) throw dispatched.error;
|
|
234
|
+
finalize(dispatched.result);
|
|
235
|
+
thinking.set('idle');
|
|
236
|
+
return dispatched.result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Backgrounded: tell XiaoZhi it's running and how to follow up. Finalize
|
|
240
|
+
// the real result once the background work completes.
|
|
241
|
+
const job = dispatched.job;
|
|
242
|
+
jobManager.wait(job.id, 3_600_000).then(() => {
|
|
243
|
+
if (job.status === 'done' && job.result) {
|
|
244
|
+
const wrapped = { isError: false, content: [{ type: 'text', text: typeof job.result === 'string' ? job.result : JSON.stringify(job.result) }] };
|
|
245
|
+
finalize(wrapped);
|
|
246
|
+
}
|
|
247
|
+
}).catch(() => {});
|
|
248
|
+
|
|
249
|
+
thinking.set('idle');
|
|
250
|
+
return {
|
|
251
|
+
isError: false,
|
|
252
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
253
|
+
status: 'running',
|
|
254
|
+
job_id: job.id,
|
|
255
|
+
tool: name,
|
|
256
|
+
action: args.action,
|
|
257
|
+
message: `"${name}${args.action ? `.${args.action}` : ''}" is running in the background (job ${job.id}). It is NOT an error or timeout. Poll it with the jobs tool: {"action":"wait","id":"${job.id}"} to wait, or {"action":"status","id":"${job.id}"} to check progress.`,
|
|
258
|
+
}) }],
|
|
259
|
+
};
|
|
198
260
|
} catch (e) {
|
|
199
261
|
stats.record(name, args.action, false, Date.now() - start, e.message);
|
|
200
262
|
thinking.set('idle');
|