pelulu-cli 1.0.5 → 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/CHANGELOG.md +81 -0
- package/package.json +10 -6
- package/src/agent/agent-controller.js +105 -0
- package/src/agent/agent-loop.js +248 -0
- package/src/agent/context-builder.js +307 -0
- package/src/agent/history-condenser.js +202 -0
- package/src/agent/index.js +8 -0
- package/src/agent/llm-client.js +44 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- 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/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +65 -6
- package/src/index.js +151 -29
- package/src/mcp/mcp-handler.js +63 -4
- package/src/mcp/mqtt-client.js +195 -12
- package/src/tools/agent.js +80 -0
- 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/completable-input.js +49 -42
- package/src/tui/ink-app.js +358 -19
- package/src/tui/ink-components.js +82 -12
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
|
@@ -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();
|
package/src/core/logger.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Logger — colored terminal output with levels
|
|
2
|
+
* Logger — colored terminal output with levels + file logging
|
|
3
3
|
* Supports Ink mode: when active, logs go through bus instead of console
|
|
4
4
|
*/
|
|
5
|
+
import { writeFile, mkdir, appendFile, readdir, unlink } from 'fs/promises';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
|
|
5
10
|
let _debug = false;
|
|
6
11
|
let _inkMode = false;
|
|
7
12
|
let _bus = null;
|
|
13
|
+
let _logFile = null;
|
|
14
|
+
let _logQueue = [];
|
|
15
|
+
let _logTimer = null;
|
|
8
16
|
|
|
9
17
|
export const COLORS = {
|
|
10
18
|
reset: '\x1b[0m',
|
|
@@ -35,9 +43,82 @@ export function setDebug(enabled) { _debug = enabled; }
|
|
|
35
43
|
export function isDebug() { return _debug; }
|
|
36
44
|
export function debug(msg, data) { log('debug', msg, data); }
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Initialize file logging
|
|
48
|
+
* Deletes old logs, keeps only the latest one
|
|
49
|
+
*/
|
|
50
|
+
export async function initFileLog(root, appName = 'pelulu') {
|
|
51
|
+
const logDir = join(root, 'logs');
|
|
52
|
+
await mkdir(logDir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
// Delete old log files
|
|
55
|
+
try {
|
|
56
|
+
const files = await readdir(logDir);
|
|
57
|
+
for (const f of files) {
|
|
58
|
+
if (f.endsWith('.log')) {
|
|
59
|
+
await unlink(join(logDir, f)).catch(() => {});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const date = now.toISOString().slice(0, 10);
|
|
66
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, '-');
|
|
67
|
+
_logFile = join(logDir, `${appName}-${date}_${time}.log`);
|
|
68
|
+
|
|
69
|
+
// Write header
|
|
70
|
+
const header = `═══════════════════════════════════════\n` +
|
|
71
|
+
`${appName} Log\n` +
|
|
72
|
+
`Started: ${now.toISOString()}\n` +
|
|
73
|
+
`═══════════════════════════════════════\n\n`;
|
|
74
|
+
await writeFile(_logFile, header, 'utf-8');
|
|
75
|
+
return _logFile;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Write log entry to file
|
|
80
|
+
*/
|
|
81
|
+
function writeToFile(level, msg, data) {
|
|
82
|
+
if (!_logFile) return;
|
|
83
|
+
|
|
84
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
85
|
+
const icon = ICONS[level] || '';
|
|
86
|
+
let line = `${ts} ${icon} ${msg}`;
|
|
87
|
+
if (data && _debug) line += ` ${JSON.stringify(data)}`;
|
|
88
|
+
line += '\n';
|
|
89
|
+
|
|
90
|
+
_logQueue.push(line);
|
|
91
|
+
_flushSoon();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Write raw text to log file (for AI responses, chat messages)
|
|
96
|
+
*/
|
|
97
|
+
export function writeRawToLog(text) {
|
|
98
|
+
if (!_logFile || !text) return;
|
|
99
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
100
|
+
_logQueue.push(`${ts} ${text}\n`);
|
|
101
|
+
_flushSoon();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _flushSoon() {
|
|
105
|
+
if (_logTimer) return;
|
|
106
|
+
_logTimer = setTimeout(async () => {
|
|
107
|
+
const batch = _logQueue.join('');
|
|
108
|
+
_logQueue = [];
|
|
109
|
+
_logTimer = null;
|
|
110
|
+
try {
|
|
111
|
+
await appendFile(_logFile, batch, 'utf-8');
|
|
112
|
+
} catch {}
|
|
113
|
+
}, 500);
|
|
114
|
+
}
|
|
115
|
+
|
|
38
116
|
export function log(level, msg, data) {
|
|
39
117
|
if (level === 'debug' && !_debug) return;
|
|
40
118
|
|
|
119
|
+
// Write to file
|
|
120
|
+
writeToFile(level, msg, data);
|
|
121
|
+
|
|
41
122
|
// In Ink mode, route logs through bus so they render inside the TUI
|
|
42
123
|
if (_inkMode && _bus) {
|
|
43
124
|
_bus.emit('log:message', { level, msg, data });
|
|
@@ -56,6 +137,30 @@ export function log(level, msg, data) {
|
|
|
56
137
|
if (data && _debug) console.log(`${COLORS.gray}${JSON.stringify(data, null, 2)}${COLORS.reset}`);
|
|
57
138
|
}
|
|
58
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Flush pending logs to file
|
|
142
|
+
*/
|
|
143
|
+
export async function flushLogs() {
|
|
144
|
+
if (_logTimer) {
|
|
145
|
+
clearTimeout(_logTimer);
|
|
146
|
+
_logTimer = null;
|
|
147
|
+
}
|
|
148
|
+
if (_logQueue.length > 0 && _logFile) {
|
|
149
|
+
const batch = _logQueue.join('');
|
|
150
|
+
_logQueue = [];
|
|
151
|
+
try {
|
|
152
|
+
await appendFile(_logFile, batch, 'utf-8');
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get log file path
|
|
159
|
+
*/
|
|
160
|
+
export function getLogFile() {
|
|
161
|
+
return _logFile;
|
|
162
|
+
}
|
|
163
|
+
|
|
59
164
|
export function table(rows) {
|
|
60
165
|
if (!rows.length) return;
|
|
61
166
|
const keys = Object.keys(rows[0]);
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SystemPrompt — builds context prompt for XiaoZhi LLM
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Now supports both legacy mode and OpenHands-style agent mode.
|
|
5
|
+
* The agent mode provides richer context and tool descriptions.
|
|
4
6
|
*/
|
|
5
7
|
|
|
8
|
+
// Re-export from agent module for backward compatibility
|
|
9
|
+
export { buildSystemPrompt as buildAgentSystemPrompt } from '../agent/system-prompt.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Legacy system prompt (for direct XiaoZhi MQTT mode)
|
|
13
|
+
*/
|
|
6
14
|
export function buildSystemPrompt(registry, config) {
|
|
7
15
|
const tools = registry.list();
|
|
16
|
+
const agentName = config.agent?.name || 'Pelulu';
|
|
8
17
|
const lines = [
|
|
9
|
-
`You are ${
|
|
10
|
-
`You have access to ${tools.length}
|
|
18
|
+
`You are ${agentName}, an AI coding agent running in Termux/Node.js.`,
|
|
19
|
+
`You have access to ${tools.length} tools. Use them to help the user.`,
|
|
11
20
|
'',
|
|
12
21
|
'## Available Tools',
|
|
13
22
|
'',
|
|
@@ -16,15 +25,21 @@ export function buildSystemPrompt(registry, config) {
|
|
|
16
25
|
for (const tool of tools) {
|
|
17
26
|
lines.push(`### ${tool.name}`);
|
|
18
27
|
lines.push(`${tool.description}`);
|
|
19
|
-
|
|
28
|
+
if (tool.actions && tool.actions.length > 0) {
|
|
29
|
+
lines.push(`Actions: ${tool.actions.map(a => typeof a === 'string' ? a : a.name).join(', ')}`);
|
|
30
|
+
}
|
|
20
31
|
lines.push('');
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
lines.push('## Tool Call Format');
|
|
24
|
-
lines.push('When you need to use a tool,
|
|
25
|
-
lines.push('
|
|
26
|
-
lines.push('
|
|
27
|
-
lines.push('
|
|
35
|
+
lines.push('When you need to use a tool, respond with a JSON object:');
|
|
36
|
+
lines.push('```json');
|
|
37
|
+
lines.push('{"tool": "tool_name", "action": "action_name", "param1": "value1", ...}');
|
|
38
|
+
lines.push('```');
|
|
39
|
+
lines.push('');
|
|
40
|
+
lines.push('Example: {"tool": "file", "action": "read", "path": "./src/index.js"}');
|
|
41
|
+
lines.push('Example: {"tool": "shell", "action": "exec", "command": "npm test"}');
|
|
42
|
+
lines.push('Example: {"tool": "git", "action": "status"}');
|
|
28
43
|
lines.push('');
|
|
29
44
|
|
|
30
45
|
lines.push('## Guidelines');
|
|
@@ -33,6 +48,7 @@ export function buildSystemPrompt(registry, config) {
|
|
|
33
48
|
lines.push('- Run tests after making changes');
|
|
34
49
|
lines.push('- Be concise in explanations');
|
|
35
50
|
lines.push('- Show code changes clearly');
|
|
51
|
+
lines.push('- When done, call: {"tool": "finish", "result": "summary of what was done"}');
|
|
36
52
|
|
|
37
53
|
return lines.join('\n');
|
|
38
54
|
}
|
|
@@ -51,24 +51,83 @@ export class ToolRegistry {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
toMcpTools() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
};
|
|
74
|
+
return this.all().map(t => {
|
|
75
|
+
const schema = t.inputSchema || { type: 'object', properties: {} };
|
|
76
|
+
const compact = { type: 'object', properties: {} };
|
|
77
|
+
// Send all properties with type + enum only (no descriptions)
|
|
78
|
+
if (schema.properties) {
|
|
79
|
+
for (const [k, v] of Object.entries(schema.properties)) {
|
|
80
|
+
const prop = { type: v.type };
|
|
81
|
+
if (v.enum) prop.enum = v.enum;
|
|
82
|
+
compact.properties[k] = prop;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
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 };
|
|
97
|
+
});
|
|
59
98
|
}
|
|
60
99
|
|
|
61
100
|
async call(name, args = {}) {
|
|
62
101
|
const tool = this.#tools.get(name);
|
|
63
102
|
if (!tool) return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
64
103
|
try {
|
|
65
|
-
const result = await tool.handler(args);
|
|
104
|
+
const result = await tool.handler(args, this._toolsRef());
|
|
66
105
|
return { isError: false, content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
67
106
|
} catch (e) {
|
|
68
107
|
return { isError: true, content: [{ type: 'text', text: e.message }] };
|
|
69
108
|
}
|
|
70
109
|
}
|
|
71
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
|
+
|
|
72
131
|
async shutdown() {
|
|
73
132
|
for (const tool of this.#tools.values()) {
|
|
74
133
|
if (tool.shutdown) await tool.shutdown();
|