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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pelulu-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "CLI coding agent powered by XiaoZhi AI — a tiny Chinese LLM that lives in ESP32 chips",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
],
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "git+https://github.com/
|
|
30
|
+
"url": "git+https://github.com/venenapro/Pelulu-CLI.git"
|
|
31
31
|
},
|
|
32
32
|
"bugs": {
|
|
33
|
-
"url": "https://github.com/
|
|
33
|
+
"url": "https://github.com/venenapro/Pelulu-CLI/issues"
|
|
34
34
|
},
|
|
35
|
-
"homepage": "https://github.com/
|
|
36
|
-
"author": "
|
|
35
|
+
"homepage": "https://github.com/venenapro/Pelulu-CLI#readme",
|
|
36
|
+
"author": "venenapro",
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"chalk": "^5.3.0",
|
|
@@ -44,5 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"ink-testing-library": "^4.0.0"
|
|
47
50
|
}
|
|
48
51
|
}
|
|
@@ -22,7 +22,12 @@ export class AgentController {
|
|
|
22
22
|
this.#sandbox = sandbox;
|
|
23
23
|
this.#confirm = confirm;
|
|
24
24
|
this.#config = config;
|
|
25
|
-
this.#loop = new AgentLoop({
|
|
25
|
+
this.#loop = new AgentLoop({
|
|
26
|
+
maxIterations: config.agent?.max_iterations || 50,
|
|
27
|
+
idleTimeoutMs: config.agent?.response_idle_ms || 45000,
|
|
28
|
+
quietMs: config.agent?.reply_quiet_ms || 2500,
|
|
29
|
+
postToolGraceMs: config.agent?.post_tool_grace_ms || 9000,
|
|
30
|
+
});
|
|
26
31
|
this.#llm = new LLMClient(mqtt);
|
|
27
32
|
this.#contextBuilder = new ContextBuilder();
|
|
28
33
|
}
|
package/src/agent/agent-loop.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AgentLoop — Core observe→think→act cycle
|
|
3
|
-
*
|
|
4
|
-
* Handles:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
3
|
+
*
|
|
4
|
+
* Handles the THREE ways XiaoZhi actually talks back:
|
|
5
|
+
* - Spoken replies via `tts:sentence` ← this is the PRIMARY reply channel
|
|
6
|
+
* - Plain text replies via `llm:text` ← rarely used, but supported
|
|
7
|
+
* - MCP tool calls via `mcp:tool_call`
|
|
8
|
+
*
|
|
9
|
+
* Turn completion is activity-aware: the loop treats text/speech/tool events as
|
|
10
|
+
* liveness and only considers the turn finished after a short quiet period, so
|
|
11
|
+
* multi-step builds (write several files, then a spoken "done") no longer look
|
|
12
|
+
* like a timeout. Previously the loop ignored `tts:sentence` entirely, so after
|
|
13
|
+
* a tool ran it waited out the full timeout and reported "XiaoZhi not
|
|
14
|
+
* responding" even though XiaoZhi had already answered by voice.
|
|
7
15
|
*/
|
|
8
16
|
import { bus } from '../core/event-bus.js';
|
|
9
17
|
import { debug } from '../core/logger.js';
|
|
@@ -22,9 +30,21 @@ export class AgentLoop {
|
|
|
22
30
|
#maxIterations;
|
|
23
31
|
#abortController = null;
|
|
24
32
|
#history = [];
|
|
33
|
+
#idleTimeoutMs;
|
|
34
|
+
#quietMs;
|
|
35
|
+
#postToolGraceMs;
|
|
25
36
|
|
|
26
|
-
constructor({ maxIterations = 50 } = {}) {
|
|
37
|
+
constructor({ maxIterations = 50, idleTimeoutMs = 45000, quietMs = 2500, postToolGraceMs = 9000 } = {}) {
|
|
27
38
|
this.#maxIterations = maxIterations;
|
|
39
|
+
// Hard cap: reject only if NOTHING at all (text, speech, tool) arrives for
|
|
40
|
+
// this long. Reset on every event, so long multi-tool turns stay alive.
|
|
41
|
+
this.#idleTimeoutMs = idleTimeoutMs;
|
|
42
|
+
// A spoken/text reply is considered complete after this much silence.
|
|
43
|
+
this.#quietMs = quietMs;
|
|
44
|
+
// After the last tool result, how long to wait for XiaoZhi to either run
|
|
45
|
+
// another tool or speak before concluding the turn as "tools done, no
|
|
46
|
+
// verbal reply". Keeps a silent voice model from hanging the whole turn.
|
|
47
|
+
this.#postToolGraceMs = postToolGraceMs;
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
get state() { return this.#state; }
|
|
@@ -39,106 +59,35 @@ export class AgentLoop {
|
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
/**
|
|
42
|
-
* Run
|
|
62
|
+
* Run one user turn.
|
|
63
|
+
*
|
|
64
|
+
* IMPORTANT — architecture: XiaoZhi is the MCP *client* and drives its own
|
|
65
|
+
* server-side think→act loop. Our CLI is the MCP *server*: `mqtt-client.js`
|
|
66
|
+
* already receives each `tools/call`, executes it, and returns the result to
|
|
67
|
+
* XiaoZhi automatically. So this loop must NOT drive tool execution or wait
|
|
68
|
+
* for tools in lock-step. It is a single, persistent OBSERVER of the whole
|
|
69
|
+
* turn's event stream. Oscillating between "wait for text" and "wait for tool
|
|
70
|
+
* result" phases (the old design) meant the final `tts:sentence` reply landed
|
|
71
|
+
* in a gap where nothing was listening — which is exactly why the turn hung
|
|
72
|
+
* and reported "XiaoZhi not responding" after files were read.
|
|
43
73
|
*/
|
|
44
|
-
async run(userPrompt, { llm
|
|
74
|
+
async run(userPrompt, { llm } = {}) {
|
|
45
75
|
this.#iteration = 0;
|
|
46
76
|
this.#abortController = new AbortController();
|
|
47
77
|
this.#history = [{ role: 'user', content: userPrompt, ts: Date.now() }];
|
|
48
|
-
this._promptSent = false;
|
|
49
78
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
await this.#sendOnce(llm);
|
|
53
|
-
|
|
54
|
-
while (this.#iteration < this.#maxIterations) {
|
|
55
|
-
this.#iteration++;
|
|
56
|
-
this.#setState(AgentState.THINKING);
|
|
57
|
-
|
|
58
|
-
// Emit progress
|
|
59
|
-
bus.emit('agent:progress', {
|
|
60
|
-
iteration: this.#iteration,
|
|
61
|
-
maxIterations: this.#maxIterations,
|
|
62
|
-
state: 'thinking',
|
|
63
|
-
message: `Thinking... (iteration ${this.#iteration})`,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
if (this.#abortController.signal.aborted) {
|
|
67
|
-
this.#setState(AgentState.FINISHED);
|
|
68
|
-
return { success: true, result: 'Aborted', iterations: this.#iteration };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Wait for response (text or tool call) — does NOT send prompt again
|
|
72
|
-
let response;
|
|
73
|
-
try {
|
|
74
|
-
response = await this.#waitForResponse();
|
|
75
|
-
} catch (err) {
|
|
76
|
-
if (err.message.includes('Timeout')) {
|
|
77
|
-
bus.emit('agent:progress', {
|
|
78
|
-
iteration: this.#iteration,
|
|
79
|
-
state: 'timeout',
|
|
80
|
-
message: 'XiaoZhi not responding, retrying...',
|
|
81
|
-
});
|
|
82
|
-
try {
|
|
83
|
-
response = await this.#waitForResponse();
|
|
84
|
-
} catch (err2) {
|
|
85
|
-
this.#setState(AgentState.FINISHED);
|
|
86
|
-
return { success: false, result: 'Timeout: XiaoZhi did not respond after 2 attempts', iterations: this.#iteration };
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
throw err;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!response) {
|
|
94
|
-
this.#setState(AgentState.FINISHED);
|
|
95
|
-
return { success: false, result: 'No response', iterations: this.#iteration };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Handle text response (final answer)
|
|
99
|
-
if (response.type === 'text') {
|
|
100
|
-
this.#history.push({ role: 'assistant', content: response.content, ts: Date.now() });
|
|
101
|
-
this.#setState(AgentState.FINISHED);
|
|
102
|
-
bus.emit('agent:progress', {
|
|
103
|
-
iteration: this.#iteration,
|
|
104
|
-
state: 'done',
|
|
105
|
-
message: 'Done!',
|
|
106
|
-
});
|
|
107
|
-
return { success: true, result: response.content, iterations: this.#iteration };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Handle tool call — wait for MQTT client to execute it, don't double-execute
|
|
111
|
-
if (response.type === 'tool_call') {
|
|
112
|
-
this.#setState(AgentState.ACTING);
|
|
113
|
-
|
|
114
|
-
bus.emit('agent:progress', {
|
|
115
|
-
iteration: this.#iteration,
|
|
116
|
-
state: 'tool',
|
|
117
|
-
tool: response.name,
|
|
118
|
-
action: response.args?.action,
|
|
119
|
-
message: `Executing ${response.name}.${response.args?.action || ''}...`,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// Wait for the tool result from MQTT client (already executed there)
|
|
123
|
-
const result = await this.#waitForToolResult(response);
|
|
124
|
-
|
|
125
|
-
this.#history.push({ role: 'tool', name: response.name, content: JSON.stringify(result), ts: Date.now() });
|
|
126
|
-
|
|
127
|
-
bus.emit('agent:progress', {
|
|
128
|
-
iteration: this.#iteration,
|
|
129
|
-
state: 'tool_done',
|
|
130
|
-
tool: response.name,
|
|
131
|
-
message: `${response.name} done, waiting for next response...`,
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
debug('agent', `Tool done, waiting for next response...`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
79
|
+
this.#setState(AgentState.THINKING);
|
|
80
|
+
bus.emit('agent:progress', { state: 'thinking', message: 'Thinking...' });
|
|
137
81
|
|
|
82
|
+
try {
|
|
83
|
+
const outcome = await this.#observeTurn(llm, userPrompt);
|
|
138
84
|
this.#setState(AgentState.FINISHED);
|
|
139
|
-
return
|
|
140
|
-
|
|
85
|
+
return outcome;
|
|
141
86
|
} catch (err) {
|
|
87
|
+
if (err.message === 'AbortError') {
|
|
88
|
+
this.#setState(AgentState.FINISHED);
|
|
89
|
+
return { success: true, result: 'Aborted', iterations: this.#iteration };
|
|
90
|
+
}
|
|
142
91
|
this.#setState(AgentState.ERROR);
|
|
143
92
|
bus.emit('agent:error', { error: err.message, iteration: this.#iteration });
|
|
144
93
|
return { success: false, result: `Error: ${err.message}`, iterations: this.#iteration };
|
|
@@ -150,127 +99,150 @@ export class AgentLoop {
|
|
|
150
99
|
}
|
|
151
100
|
|
|
152
101
|
/**
|
|
153
|
-
*
|
|
102
|
+
* Observe a full turn with ONE set of listeners that stay attached from the
|
|
103
|
+
* moment the prompt is sent until the turn settles. Nothing is ever missed
|
|
104
|
+
* between tool calls.
|
|
105
|
+
*
|
|
106
|
+
* Completion rules (whichever fires first):
|
|
107
|
+
* - Spoken/text reply settles: buffer got text, then went quiet for
|
|
108
|
+
* #quietMs, and no tool call is in flight → success with the reply.
|
|
109
|
+
* - Tools finished silently: XiaoZhi ran tools and produced no verbal
|
|
110
|
+
* reply within #postToolGraceMs of the last result → success, reported
|
|
111
|
+
* as the tool activity (prevents the long hang the user was seeing).
|
|
112
|
+
* - Hard idle: absolutely nothing (text, speech, tool call, tool result)
|
|
113
|
+
* for #idleTimeoutMs → treated as a stalled turn.
|
|
154
114
|
*/
|
|
155
|
-
|
|
156
|
-
if (this._promptSent) return;
|
|
157
|
-
this._promptSent = true;
|
|
158
|
-
debug('agent', `Sending prompt: ${this.#history[0].content}`);
|
|
159
|
-
await llm.sendPrompt(this.#history[0].content);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Wait for response from XiaoZhi (text or MCP tool call)
|
|
164
|
-
* Does NOT send prompt — that's done once in #sendOnce
|
|
165
|
-
*/
|
|
166
|
-
#waitForResponse() {
|
|
115
|
+
#observeTurn(llm, userPrompt) {
|
|
167
116
|
return new Promise((resolve, reject) => {
|
|
168
|
-
let
|
|
117
|
+
let settled = false;
|
|
169
118
|
let buffer = '';
|
|
170
|
-
let
|
|
171
|
-
let
|
|
172
|
-
|
|
119
|
+
let pendingTools = 0; // tool calls started but not yet resulted
|
|
120
|
+
let toolsRun = 0; // total tool calls seen this turn
|
|
121
|
+
let lastTool = null; // name of the most recent tool
|
|
122
|
+
let quietTimer = null; // fires when a verbal reply goes quiet
|
|
123
|
+
let graceTimer = null; // fires when tools finish with no verbal reply
|
|
124
|
+
let idleTimer = null; // hard "nothing at all is happening" cap
|
|
125
|
+
|
|
126
|
+
const clearTimers = () => {
|
|
127
|
+
clearTimeout(quietTimer); clearTimeout(graceTimer); clearTimeout(idleTimer);
|
|
128
|
+
};
|
|
173
129
|
const cleanup = () => {
|
|
174
|
-
|
|
130
|
+
clearTimers();
|
|
175
131
|
bus.off('llm:text', onText);
|
|
176
|
-
bus.off('
|
|
132
|
+
bus.off('tts:sentence', onText);
|
|
133
|
+
bus.off('mcp:tool_call', onToolCall);
|
|
134
|
+
bus.off('mcp:tool_result', onToolResult);
|
|
177
135
|
};
|
|
178
136
|
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
bus.emit('agent:progress', {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
});
|
|
137
|
+
const finish = (outcome) => {
|
|
138
|
+
if (settled) return;
|
|
139
|
+
settled = true;
|
|
140
|
+
cleanup();
|
|
141
|
+
bus.emit('agent:progress', { state: 'done', message: 'Done!' });
|
|
142
|
+
resolve(outcome);
|
|
143
|
+
};
|
|
187
144
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
}, 2000);
|
|
145
|
+
const finishWithText = () => {
|
|
146
|
+
if (pendingTools > 0) return; // a tool is still running; not done yet
|
|
147
|
+
const content = buffer.trim();
|
|
148
|
+
if (!content) return;
|
|
149
|
+
this.#history.push({ role: 'assistant', content, ts: Date.now() });
|
|
150
|
+
finish({ success: true, result: content, iterations: this.#iteration });
|
|
196
151
|
};
|
|
197
152
|
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
resolve({ type: 'tool_call', ...data });
|
|
153
|
+
const finishSilentTools = () => {
|
|
154
|
+
if (settled || pendingTools > 0 || buffer.trim()) return;
|
|
155
|
+
const summary = `Completed ${toolsRun} tool ${toolsRun === 1 ? 'action' : 'actions'}${lastTool ? ` (last: ${lastTool})` : ''}.`;
|
|
156
|
+
this.#history.push({ role: 'assistant', content: summary, ts: Date.now() });
|
|
157
|
+
finish({ success: true, result: summary, iterations: this.#iteration });
|
|
204
158
|
};
|
|
205
159
|
|
|
206
|
-
//
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
160
|
+
// Reset the hard idle cap on every sign of life.
|
|
161
|
+
const armIdle = () => {
|
|
162
|
+
clearTimeout(idleTimer);
|
|
163
|
+
idleTimer = setTimeout(() => {
|
|
164
|
+
if (settled) return;
|
|
165
|
+
if (buffer.trim()) { finishWithText(); return; }
|
|
166
|
+
if (toolsRun > 0) { finishSilentTools(); return; }
|
|
167
|
+
settled = true;
|
|
210
168
|
cleanup();
|
|
211
169
|
reject(new Error('Timeout: XiaoZhi not responding'));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Abort handler
|
|
216
|
-
this.#abortController.signal.addEventListener('abort', () => {
|
|
217
|
-
if (!resolved) {
|
|
218
|
-
resolved = true;
|
|
219
|
-
cleanup();
|
|
220
|
-
clearTimeout(timeout);
|
|
221
|
-
reject(new Error('AbortError'));
|
|
222
|
-
}
|
|
223
|
-
}, { once: true });
|
|
170
|
+
}, this.#idleTimeoutMs);
|
|
171
|
+
};
|
|
224
172
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
173
|
+
const onText = (text) => {
|
|
174
|
+
if (settled || typeof text !== 'string') return;
|
|
175
|
+
buffer += (buffer ? ' ' : '') + text;
|
|
176
|
+
clearTimeout(graceTimer); // a verbal reply supersedes the silent-tool path
|
|
177
|
+
armIdle();
|
|
178
|
+
bus.emit('agent:progress', { state: 'receiving', message: `Receiving ${buffer.length} chars...` });
|
|
179
|
+
clearTimeout(quietTimer);
|
|
180
|
+
quietTimer = setTimeout(finishWithText, this.#quietMs);
|
|
181
|
+
};
|
|
229
182
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
183
|
+
const onToolCall = ({ name, args }) => {
|
|
184
|
+
if (settled) return;
|
|
185
|
+
pendingTools++;
|
|
186
|
+
toolsRun++;
|
|
187
|
+
lastTool = name;
|
|
188
|
+
this.#iteration++;
|
|
189
|
+
this.#setState(AgentState.ACTING);
|
|
190
|
+
// Text spoken BEFORE a tool call is just "thinking out loud" filler
|
|
191
|
+
// (e.g. "% file...") — the real answer is what XiaoZhi says after the
|
|
192
|
+
// tools finish. Drop the filler so the final result is clean.
|
|
193
|
+
buffer = '';
|
|
194
|
+
clearTimeout(quietTimer); // don't finish text while a tool is running
|
|
195
|
+
clearTimeout(graceTimer);
|
|
196
|
+
armIdle();
|
|
197
|
+
bus.emit('agent:progress', {
|
|
198
|
+
state: 'tool', tool: name, action: args?.action,
|
|
199
|
+
iteration: this.#iteration,
|
|
200
|
+
message: `Running ${name}${args?.action ? '.' + args.action : ''}...`,
|
|
201
|
+
});
|
|
202
|
+
};
|
|
237
203
|
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
204
|
+
const onToolResult = ({ name, result }) => {
|
|
205
|
+
if (settled) return;
|
|
206
|
+
if (pendingTools > 0) pendingTools--;
|
|
207
|
+
this.#history.push({ role: 'tool', name, content: JSON.stringify(result), ts: Date.now() });
|
|
208
|
+
this.#setState(AgentState.THINKING);
|
|
209
|
+
armIdle();
|
|
210
|
+
bus.emit('agent:progress', {
|
|
211
|
+
state: 'tool_done', tool: name,
|
|
212
|
+
message: `${name} done${result?.isError ? ' (error)' : ''}, waiting for next step...`,
|
|
213
|
+
});
|
|
214
|
+
if (pendingTools === 0) {
|
|
215
|
+
// All in-flight tools are done. Give XiaoZhi a grace window to either
|
|
216
|
+
// fire another tool call or speak a final reply. If it stays silent,
|
|
217
|
+
// conclude the turn instead of hanging until the idle timeout.
|
|
218
|
+
clearTimeout(graceTimer);
|
|
219
|
+
graceTimer = setTimeout(finishSilentTools, this.#postToolGraceMs);
|
|
246
220
|
}
|
|
247
221
|
};
|
|
248
222
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
const result = await this.#execToolDirect(call);
|
|
257
|
-
resolve(result);
|
|
258
|
-
} catch (err) {
|
|
259
|
-
resolve({ isError: true, content: [{ type: 'text', text: err.message }] });
|
|
260
|
-
}
|
|
261
|
-
}, 10000);
|
|
223
|
+
this.#abortController.signal.addEventListener('abort', () => {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new Error('AbortError'));
|
|
228
|
+
}, { once: true });
|
|
262
229
|
|
|
263
|
-
bus.on('
|
|
230
|
+
bus.on('llm:text', onText);
|
|
231
|
+
bus.on('tts:sentence', onText);
|
|
232
|
+
bus.on('mcp:tool_call', onToolCall);
|
|
233
|
+
bus.on('mcp:tool_result', onToolResult);
|
|
234
|
+
|
|
235
|
+
armIdle();
|
|
236
|
+
|
|
237
|
+
// Send the prompt exactly once, after listeners are attached so we never
|
|
238
|
+
// miss an immediate reply.
|
|
239
|
+
debug('agent', `Sending prompt: ${userPrompt}`);
|
|
240
|
+
Promise.resolve(llm?.sendPrompt(userPrompt)).catch((err) => {
|
|
241
|
+
if (settled) return;
|
|
242
|
+
settled = true;
|
|
243
|
+
cleanup();
|
|
244
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
245
|
+
});
|
|
264
246
|
});
|
|
265
247
|
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Direct tool execution (fallback only)
|
|
269
|
-
*/
|
|
270
|
-
async #execToolDirect(call) {
|
|
271
|
-
return { isError: true, content: [{ type: 'text', text: 'Tool execution via MQTT timed out' }] };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Tool execution removed — handled by MQTT client
|
|
275
|
-
// Agent loop only waits for results via mcp:tool_result events
|
|
276
248
|
}
|
package/src/agent/llm-client.js
CHANGED
|
@@ -20,18 +20,12 @@ export class LLMClient {
|
|
|
20
20
|
*/
|
|
21
21
|
async #waitForReady() {
|
|
22
22
|
if (this.#mqtt.mcp?.toolsReceived && this.#mqtt.sessionId) return;
|
|
23
|
-
debug('llm', '
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
clearTimeout(timeout);
|
|
30
|
-
debug('llm', 'MCP ready');
|
|
31
|
-
resolve();
|
|
32
|
-
}
|
|
33
|
-
}, 200);
|
|
34
|
-
});
|
|
23
|
+
debug('llm', 'Session not ready — re-establishing...');
|
|
24
|
+
// Actively re-send hello to recover from an idle `goodbye`, rather than
|
|
25
|
+
// polling for a session that will never come back on its own.
|
|
26
|
+
const ok = await this.#mqtt.ensureSession(30000);
|
|
27
|
+
if (!ok) throw new Error('MCP handshake timeout');
|
|
28
|
+
debug('llm', 'MCP ready');
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
/**
|
package/src/core/config.js
CHANGED
|
@@ -30,7 +30,7 @@ let _config = null;
|
|
|
30
30
|
export async function loadConfig(root) {
|
|
31
31
|
const path = join(root, CONFIG_FILE);
|
|
32
32
|
const defaults = {
|
|
33
|
-
agent: { name: 'Pelulu CLI', version: '1.
|
|
33
|
+
agent: { name: 'Pelulu CLI', version: '1.1.0', workspace: '~/Pelulu-CLI' },
|
|
34
34
|
mqtt: { ota_url: 'https://api.tenclass.net/xiaozhi/ota/', keepalive: 240 },
|
|
35
35
|
mcp: { endpoint_url: '' },
|
|
36
36
|
tools: { shell_timeout: 30000, max_output: 10000, auto_format: true },
|