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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **Pelulu CLI** will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.1.0] - 2026-07-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **MQTT disconnect loop** — `tools/list` response exceeded 8KB broker limit, now optimized to ~4KB
|
|
9
|
+
- **Agent sends prompt before session ready** — `LLMClient` now waits for MCP handshake + session before sending
|
|
10
|
+
- **Double tool execution** — agent loop no longer re-executes tools already handled by MQTT client
|
|
11
|
+
- **Prompt sent multiple times** — agent loop now sends prompt only once per run
|
|
12
|
+
- **AI responses not in log file** — added `llm:text` and `tts:sentence` event logging
|
|
13
|
+
- **Log files accumulate** — old logs deleted on startup, only latest kept
|
|
14
|
+
- **y/N confirmation prompt flashing** — agent tool calls auto-approved (no more UI glitches)
|
|
15
|
+
- **Version mismatch** — version now read from `package.json` (auto-sync)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **Statusbar redesigned** — clean minimal layout: `🐱 PELULU ● online session:xxx xiaozhi.me`
|
|
19
|
+
- **Tool schema optimization** — `action` enum preserved, first 4 properties sent (under 8KB)
|
|
20
|
+
- **System prompt added to hello message** — tells XiaoZhi to use tools instead of just chatting
|
|
21
|
+
|
|
22
|
+
## [1.0.5] - 2026-07-20
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- File logging (`logs/` directory)
|
|
26
|
+
- Char counter on TUI input (70 char limit)
|
|
27
|
+
- Scrollable chat (Shift+Up/Down, PgUp/PgDn)
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Chat limited to 12 visible lines
|
|
31
|
+
- Progress feedback and timeout handling in agent loop
|
|
32
|
+
|
|
33
|
+
## [1.0.4] - 2026-07-19
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- Update checker uses npm registry instead of GitHub releases
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- Activation code displays directly to console
|
|
40
|
+
|
|
41
|
+
## [1.0.3] - 2026-07-19
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- Duplicate user messages (Enter key fired onSubmit twice)
|
|
45
|
+
- TTS responses handled as assistant messages
|
|
46
|
+
|
|
47
|
+
## [1.0.2] - 2026-07-18
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
- Cute ASCII cat banner above CLI header
|
|
51
|
+
- Block CLI when update is available
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- Redirect ALL startup logs into Ink TUI
|
|
55
|
+
- Clean status bar header format
|
|
56
|
+
|
|
57
|
+
## [1.0.1] - 2026-07-17
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
- OpenHands-style agent system (AgentLoop, AgentController, LLMClient)
|
|
61
|
+
- Auto-completion with Tab key
|
|
62
|
+
- Auto-format for written files
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
- Replaced readline with Ink React TUI
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
- Compact banner text
|
|
69
|
+
- TTY fallback for non-interactive mode
|
|
70
|
+
- Log status as single-line auto-updating text
|
|
71
|
+
|
|
72
|
+
## [1.0.0] - 2026-07-16
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
- Initial release
|
|
76
|
+
- XiaoZhi AI integration via MQTT
|
|
77
|
+
- MCP tool protocol support
|
|
78
|
+
- 18 built-in tools (file, shell, git, project, network, etc.)
|
|
79
|
+
- Interactive REPL with syntax highlighting
|
|
80
|
+
- Plugin system
|
|
81
|
+
- Workspace detection and context building
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pelulu-cli",
|
|
3
|
-
"version": "1.0
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"pelulu": "src/index.js"
|
|
8
|
+
"pelulu-cli": "src/index.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node src/index.js",
|
|
12
12
|
"start:debug": "node src/index.js --debug",
|
|
13
13
|
"start:fallback": "node src/index.js --no-ink",
|
|
14
|
+
"start:no-agent": "node src/index.js --no-agent",
|
|
14
15
|
"tools": "node src/index.js --list-tools",
|
|
15
16
|
"test": "node src/index.js --test"
|
|
16
17
|
},
|
|
@@ -26,13 +27,13 @@
|
|
|
26
27
|
],
|
|
27
28
|
"repository": {
|
|
28
29
|
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/
|
|
30
|
+
"url": "git+https://github.com/venenapro/Pelulu-CLI.git"
|
|
30
31
|
},
|
|
31
32
|
"bugs": {
|
|
32
|
-
"url": "https://github.com/
|
|
33
|
+
"url": "https://github.com/venenapro/Pelulu-CLI/issues"
|
|
33
34
|
},
|
|
34
|
-
"homepage": "https://github.com/
|
|
35
|
-
"author": "
|
|
35
|
+
"homepage": "https://github.com/venenapro/Pelulu-CLI#readme",
|
|
36
|
+
"author": "venenapro",
|
|
36
37
|
"license": "MIT",
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"chalk": "^5.3.0",
|
|
@@ -43,5 +44,8 @@
|
|
|
43
44
|
},
|
|
44
45
|
"engines": {
|
|
45
46
|
"node": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"ink-testing-library": "^4.0.0"
|
|
46
50
|
}
|
|
47
51
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentController — Orchestrates the agent system
|
|
3
|
+
*/
|
|
4
|
+
import { AgentLoop } from './agent-loop.js';
|
|
5
|
+
import { LLMClient } from './llm-client.js';
|
|
6
|
+
import { ContextBuilder } from './context-builder.js';
|
|
7
|
+
import { bus } from '../core/event-bus.js';
|
|
8
|
+
import { debug } from '../core/logger.js';
|
|
9
|
+
|
|
10
|
+
export class AgentController {
|
|
11
|
+
#loop;
|
|
12
|
+
#llm;
|
|
13
|
+
#contextBuilder;
|
|
14
|
+
#registry;
|
|
15
|
+
#sandbox;
|
|
16
|
+
#confirm;
|
|
17
|
+
#config;
|
|
18
|
+
#running = false;
|
|
19
|
+
|
|
20
|
+
constructor({ registry, mqtt, sandbox, confirm, config }) {
|
|
21
|
+
this.#registry = registry;
|
|
22
|
+
this.#sandbox = sandbox;
|
|
23
|
+
this.#confirm = confirm;
|
|
24
|
+
this.#config = config;
|
|
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
|
+
});
|
|
31
|
+
this.#llm = new LLMClient(mqtt);
|
|
32
|
+
this.#contextBuilder = new ContextBuilder();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get isRunning() { return this.#running; }
|
|
36
|
+
|
|
37
|
+
get summary() {
|
|
38
|
+
return {
|
|
39
|
+
state: this.#loop.state,
|
|
40
|
+
iteration: this.#loop.iteration,
|
|
41
|
+
history: this.#loop.history.length,
|
|
42
|
+
running: this.#running,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run agent for a user prompt
|
|
48
|
+
*/
|
|
49
|
+
async run(userPrompt, options = {}) {
|
|
50
|
+
// Validate input length
|
|
51
|
+
const MAX_LEN = 70;
|
|
52
|
+
if (userPrompt.length > MAX_LEN) {
|
|
53
|
+
throw new Error(`Input too long (${userPrompt.length}/${MAX_LEN} chars)`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (this.#running) {
|
|
57
|
+
this.abort();
|
|
58
|
+
await new Promise(r => setTimeout(r, 300));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.#running = true;
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
debug('agent', `Running: ${userPrompt}`);
|
|
66
|
+
|
|
67
|
+
const result = await this.#loop.run(userPrompt, {
|
|
68
|
+
llm: this.#llm,
|
|
69
|
+
tools: this.#registry,
|
|
70
|
+
sandbox: this.#sandbox,
|
|
71
|
+
confirm: this.#confirm,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
result.duration = Date.now() - start;
|
|
75
|
+
debug('agent', `Done in ${result.duration}ms: ${result.success}`);
|
|
76
|
+
return result;
|
|
77
|
+
|
|
78
|
+
} finally {
|
|
79
|
+
this.#running = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Abort current run
|
|
85
|
+
*/
|
|
86
|
+
abort() {
|
|
87
|
+
this.#loop.abort();
|
|
88
|
+
this.#running = false;
|
|
89
|
+
debug('agent', 'Aborted');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get workspace context
|
|
94
|
+
*/
|
|
95
|
+
async getContext() {
|
|
96
|
+
return this.#contextBuilder.build();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reset agent state
|
|
101
|
+
*/
|
|
102
|
+
reset() {
|
|
103
|
+
this.#running = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentLoop — Core observe→think→act cycle
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
import { bus } from '../core/event-bus.js';
|
|
17
|
+
import { debug } from '../core/logger.js';
|
|
18
|
+
|
|
19
|
+
export const AgentState = {
|
|
20
|
+
IDLE: 'idle',
|
|
21
|
+
THINKING: 'thinking',
|
|
22
|
+
ACTING: 'acting',
|
|
23
|
+
FINISHED: 'finished',
|
|
24
|
+
ERROR: 'error',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class AgentLoop {
|
|
28
|
+
#state = AgentState.IDLE;
|
|
29
|
+
#iteration = 0;
|
|
30
|
+
#maxIterations;
|
|
31
|
+
#abortController = null;
|
|
32
|
+
#history = [];
|
|
33
|
+
#idleTimeoutMs;
|
|
34
|
+
#quietMs;
|
|
35
|
+
#postToolGraceMs;
|
|
36
|
+
|
|
37
|
+
constructor({ maxIterations = 50, idleTimeoutMs = 45000, quietMs = 2500, postToolGraceMs = 9000 } = {}) {
|
|
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;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get state() { return this.#state; }
|
|
51
|
+
get iteration() { return this.#iteration; }
|
|
52
|
+
get history() { return [...this.#history]; }
|
|
53
|
+
|
|
54
|
+
#setState(s) {
|
|
55
|
+
const old = this.#state;
|
|
56
|
+
this.#state = s;
|
|
57
|
+
debug('agent', `${old} → ${s}`);
|
|
58
|
+
bus.emit('agent:state', { from: old, to: s });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
async run(userPrompt, { llm } = {}) {
|
|
75
|
+
this.#iteration = 0;
|
|
76
|
+
this.#abortController = new AbortController();
|
|
77
|
+
this.#history = [{ role: 'user', content: userPrompt, ts: Date.now() }];
|
|
78
|
+
|
|
79
|
+
this.#setState(AgentState.THINKING);
|
|
80
|
+
bus.emit('agent:progress', { state: 'thinking', message: 'Thinking...' });
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const outcome = await this.#observeTurn(llm, userPrompt);
|
|
84
|
+
this.#setState(AgentState.FINISHED);
|
|
85
|
+
return outcome;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err.message === 'AbortError') {
|
|
88
|
+
this.#setState(AgentState.FINISHED);
|
|
89
|
+
return { success: true, result: 'Aborted', iterations: this.#iteration };
|
|
90
|
+
}
|
|
91
|
+
this.#setState(AgentState.ERROR);
|
|
92
|
+
bus.emit('agent:error', { error: err.message, iteration: this.#iteration });
|
|
93
|
+
return { success: false, result: `Error: ${err.message}`, iterations: this.#iteration };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
abort() {
|
|
98
|
+
this.#abortController?.abort();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
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.
|
|
114
|
+
*/
|
|
115
|
+
#observeTurn(llm, userPrompt) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let settled = false;
|
|
118
|
+
let buffer = '';
|
|
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
|
+
};
|
|
129
|
+
const cleanup = () => {
|
|
130
|
+
clearTimers();
|
|
131
|
+
bus.off('llm:text', onText);
|
|
132
|
+
bus.off('tts:sentence', onText);
|
|
133
|
+
bus.off('mcp:tool_call', onToolCall);
|
|
134
|
+
bus.off('mcp:tool_result', onToolResult);
|
|
135
|
+
};
|
|
136
|
+
|
|
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
|
+
};
|
|
144
|
+
|
|
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 });
|
|
151
|
+
};
|
|
152
|
+
|
|
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 });
|
|
158
|
+
};
|
|
159
|
+
|
|
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;
|
|
168
|
+
cleanup();
|
|
169
|
+
reject(new Error('Timeout: XiaoZhi not responding'));
|
|
170
|
+
}, this.#idleTimeoutMs);
|
|
171
|
+
};
|
|
172
|
+
|
|
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
|
+
};
|
|
182
|
+
|
|
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
|
+
};
|
|
203
|
+
|
|
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);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
this.#abortController.signal.addEventListener('abort', () => {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new Error('AbortError'));
|
|
228
|
+
}, { once: true });
|
|
229
|
+
|
|
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
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|