@heysalad/cheri-cli 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cheri.js +7 -1
- package/package.json +4 -1
- package/src/commands/agent.js +317 -85
- package/src/lib/approval.js +120 -0
- package/src/lib/command-safety.js +170 -0
- package/src/lib/config-store.js +142 -37
- package/src/lib/context.js +103 -37
- package/src/lib/diff-tracker.js +157 -0
- package/src/lib/logger.js +15 -1
- package/src/lib/markdown.js +62 -0
- package/src/lib/mcp/client.js +239 -0
- package/src/lib/multi-agent.js +153 -0
- package/src/lib/providers/index.js +285 -0
- package/src/lib/sandbox.js +164 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// File diff tracking — tracks changes made by the agent for review and rollback
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { createTwoFilesPatch, structuredPatch } from "diff";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
|
|
7
|
+
const SNAPSHOTS_DIR = join(homedir(), ".cheri", "snapshots");
|
|
8
|
+
|
|
9
|
+
// In-memory change log for current session
|
|
10
|
+
let sessionChanges = [];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Take a snapshot of a file before modification
|
|
14
|
+
*/
|
|
15
|
+
export function snapshotFile(filePath) {
|
|
16
|
+
const absPath = filePath.startsWith("/") ? filePath : join(process.cwd(), filePath);
|
|
17
|
+
let content = "";
|
|
18
|
+
let existed = false;
|
|
19
|
+
|
|
20
|
+
if (existsSync(absPath)) {
|
|
21
|
+
try {
|
|
22
|
+
content = readFileSync(absPath, "utf-8");
|
|
23
|
+
existed = true;
|
|
24
|
+
} catch {
|
|
25
|
+
content = "";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { path: absPath, content, existed, timestamp: Date.now() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Record a file change (call after modification)
|
|
34
|
+
*/
|
|
35
|
+
export function recordChange(snapshot, newContent, operation = "edit") {
|
|
36
|
+
const change = {
|
|
37
|
+
path: snapshot.path,
|
|
38
|
+
operation,
|
|
39
|
+
before: snapshot.content,
|
|
40
|
+
after: newContent,
|
|
41
|
+
existed: snapshot.existed,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
sessionChanges.push(change);
|
|
46
|
+
|
|
47
|
+
// Persist snapshot for rollback
|
|
48
|
+
try {
|
|
49
|
+
if (!existsSync(SNAPSHOTS_DIR)) mkdirSync(SNAPSHOTS_DIR, { recursive: true });
|
|
50
|
+
const snapshotFile = join(SNAPSHOTS_DIR, `${Date.now()}-${sanitizeFilename(snapshot.path)}`);
|
|
51
|
+
writeFileSync(snapshotFile, JSON.stringify(change), "utf-8");
|
|
52
|
+
} catch {}
|
|
53
|
+
|
|
54
|
+
return change;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate a unified diff for a change
|
|
59
|
+
*/
|
|
60
|
+
export function generateDiff(change) {
|
|
61
|
+
return createTwoFilesPatch(
|
|
62
|
+
change.path,
|
|
63
|
+
change.path,
|
|
64
|
+
change.before,
|
|
65
|
+
change.after,
|
|
66
|
+
"before",
|
|
67
|
+
"after"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get summary of changes in current session
|
|
73
|
+
*/
|
|
74
|
+
export function getSessionChanges() {
|
|
75
|
+
return sessionChanges.map(c => ({
|
|
76
|
+
path: c.path,
|
|
77
|
+
operation: c.operation,
|
|
78
|
+
linesAdded: countLines(c.after) - countLines(c.before),
|
|
79
|
+
timestamp: c.timestamp,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get detailed diff for a specific change
|
|
85
|
+
*/
|
|
86
|
+
export function getChangeDiff(index) {
|
|
87
|
+
if (index < 0 || index >= sessionChanges.length) return null;
|
|
88
|
+
return generateDiff(sessionChanges[index]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Rollback a specific change
|
|
93
|
+
*/
|
|
94
|
+
export function rollbackChange(index) {
|
|
95
|
+
if (index < 0 || index >= sessionChanges.length) {
|
|
96
|
+
return { error: "Invalid change index" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const change = sessionChanges[index];
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
if (!change.existed) {
|
|
103
|
+
// File was created — delete it
|
|
104
|
+
const { unlinkSync } = require("fs");
|
|
105
|
+
unlinkSync(change.path);
|
|
106
|
+
} else {
|
|
107
|
+
// Restore original content
|
|
108
|
+
const dir = dirname(change.path);
|
|
109
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
110
|
+
writeFileSync(change.path, change.before, "utf-8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Remove from session changes
|
|
114
|
+
sessionChanges.splice(index, 1);
|
|
115
|
+
return { success: true, path: change.path };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { error: err.message };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Rollback all changes in reverse order
|
|
123
|
+
*/
|
|
124
|
+
export function rollbackAll() {
|
|
125
|
+
const results = [];
|
|
126
|
+
for (let i = sessionChanges.length - 1; i >= 0; i--) {
|
|
127
|
+
results.push(rollbackChange(i));
|
|
128
|
+
}
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Clear session changes (without rolling back)
|
|
134
|
+
*/
|
|
135
|
+
export function clearSessionChanges() {
|
|
136
|
+
sessionChanges = [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get a structured diff with hunks
|
|
141
|
+
*/
|
|
142
|
+
export function getStructuredDiff(change) {
|
|
143
|
+
return structuredPatch(
|
|
144
|
+
change.path, change.path,
|
|
145
|
+
change.before, change.after,
|
|
146
|
+
"before", "after"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function countLines(text) {
|
|
151
|
+
if (!text) return 0;
|
|
152
|
+
return text.split("\n").length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sanitizeFilename(path) {
|
|
156
|
+
return path.replace(/[^a-zA-Z0-9.-]/g, "_").slice(-60);
|
|
157
|
+
}
|
package/src/lib/logger.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
let _version;
|
|
8
|
+
function getVersion() {
|
|
9
|
+
if (!_version) {
|
|
10
|
+
try { _version = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")).version; }
|
|
11
|
+
catch { _version = "0.0.0"; }
|
|
12
|
+
}
|
|
13
|
+
return _version;
|
|
14
|
+
}
|
|
2
15
|
|
|
3
16
|
export const log = {
|
|
4
17
|
info(msg) {
|
|
@@ -36,7 +49,8 @@ export const log = {
|
|
|
36
49
|
console.log(chalk.dim(prefix) + " " + item);
|
|
37
50
|
});
|
|
38
51
|
},
|
|
39
|
-
banner(version
|
|
52
|
+
banner(version) {
|
|
53
|
+
if (!version) version = getVersion();
|
|
40
54
|
console.log();
|
|
41
55
|
console.log(` ${chalk.red("🍒")} ${chalk.red.bold("Cheri")}`);
|
|
42
56
|
console.log(` ${chalk.dim("AI-powered cloud IDE by HeySalad")}`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Rich markdown rendering for terminal output
|
|
2
|
+
import { Marked } from "marked";
|
|
3
|
+
import { markedTerminal } from "marked-terminal";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
|
|
6
|
+
const marked = new Marked(markedTerminal({
|
|
7
|
+
reflowText: true,
|
|
8
|
+
width: Math.min(process.stdout.columns || 100, 120),
|
|
9
|
+
showSectionPrefix: false,
|
|
10
|
+
tab: 2,
|
|
11
|
+
code: chalk.cyan,
|
|
12
|
+
codespan: chalk.cyan,
|
|
13
|
+
blockquote: chalk.dim.italic,
|
|
14
|
+
strong: chalk.bold,
|
|
15
|
+
em: chalk.italic,
|
|
16
|
+
del: chalk.strikethrough,
|
|
17
|
+
link: chalk.blue.underline,
|
|
18
|
+
href: chalk.blue.underline,
|
|
19
|
+
listitem: (text) => ` ${chalk.dim("•")} ${text}`,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render markdown text to terminal-formatted string
|
|
24
|
+
*/
|
|
25
|
+
export function renderMarkdown(text) {
|
|
26
|
+
if (!text) return "";
|
|
27
|
+
try {
|
|
28
|
+
return marked.parse(text).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stream-friendly markdown rendering for partial text
|
|
36
|
+
* Returns the text as-is for streaming (full render at end)
|
|
37
|
+
*/
|
|
38
|
+
export function streamText(text) {
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render a code block with language label
|
|
44
|
+
*/
|
|
45
|
+
export function renderCodeBlock(code, language = "") {
|
|
46
|
+
const header = language ? chalk.dim(` ${language}`) + "\n" : "";
|
|
47
|
+
const lines = code.split("\n").map(l => chalk.cyan(" " + l)).join("\n");
|
|
48
|
+
return header + lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render a diff with color coding
|
|
53
|
+
*/
|
|
54
|
+
export function renderDiff(diffText) {
|
|
55
|
+
return diffText.split("\n").map(line => {
|
|
56
|
+
if (line.startsWith("+") && !line.startsWith("+++")) return chalk.green(line);
|
|
57
|
+
if (line.startsWith("-") && !line.startsWith("---")) return chalk.red(line);
|
|
58
|
+
if (line.startsWith("@@")) return chalk.cyan(line);
|
|
59
|
+
if (line.startsWith("diff")) return chalk.bold(line);
|
|
60
|
+
return chalk.dim(line);
|
|
61
|
+
}).join("\n");
|
|
62
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// MCP (Model Context Protocol) client
|
|
2
|
+
// Connects to MCP servers via stdio transport, discovers tools, and calls them
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
|
|
6
|
+
const JSONRPC_VERSION = "2.0";
|
|
7
|
+
let requestId = 0;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MCP Server connection
|
|
11
|
+
*/
|
|
12
|
+
export class McpServer {
|
|
13
|
+
constructor(name, command, args = [], env = {}) {
|
|
14
|
+
this.name = name;
|
|
15
|
+
this.command = command;
|
|
16
|
+
this.args = args;
|
|
17
|
+
this.env = { ...process.env, ...env };
|
|
18
|
+
this.process = null;
|
|
19
|
+
this.tools = [];
|
|
20
|
+
this.resources = [];
|
|
21
|
+
this.pendingRequests = new Map();
|
|
22
|
+
this._buffer = "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async connect() {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
this.process = spawn(this.command, this.args, {
|
|
28
|
+
env: this.env,
|
|
29
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
this.process.stdout.on("data", (data) => {
|
|
33
|
+
this._buffer += data.toString();
|
|
34
|
+
this._processBuffer();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
this.process.stderr.on("data", (data) => {
|
|
38
|
+
log.dim(`[mcp:${this.name}] ${data.toString().trim()}`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
this.process.on("error", (err) => {
|
|
42
|
+
reject(new Error(`MCP server ${this.name} failed to start: ${err.message}`));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.process.on("close", (code) => {
|
|
46
|
+
for (const [, { reject: r }] of this.pendingRequests) {
|
|
47
|
+
r(new Error(`MCP server ${this.name} exited with code ${code}`));
|
|
48
|
+
}
|
|
49
|
+
this.pendingRequests.clear();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Initialize the connection
|
|
53
|
+
setTimeout(async () => {
|
|
54
|
+
try {
|
|
55
|
+
await this._initialize();
|
|
56
|
+
resolve();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
reject(err);
|
|
59
|
+
}
|
|
60
|
+
}, 500);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async _initialize() {
|
|
65
|
+
const result = await this._request("initialize", {
|
|
66
|
+
protocolVersion: "2024-11-05",
|
|
67
|
+
capabilities: {},
|
|
68
|
+
clientInfo: { name: "cheri", version: "0.10.0" },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Send initialized notification
|
|
72
|
+
this._notify("notifications/initialized", {});
|
|
73
|
+
|
|
74
|
+
// Discover tools
|
|
75
|
+
try {
|
|
76
|
+
const toolsResult = await this._request("tools/list", {});
|
|
77
|
+
this.tools = toolsResult.tools || [];
|
|
78
|
+
} catch {
|
|
79
|
+
this.tools = [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Discover resources
|
|
83
|
+
try {
|
|
84
|
+
const resourcesResult = await this._request("resources/list", {});
|
|
85
|
+
this.resources = resourcesResult.resources || [];
|
|
86
|
+
} catch {
|
|
87
|
+
this.resources = [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async callTool(name, args = {}) {
|
|
94
|
+
return this._request("tools/call", { name, arguments: args });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async readResource(uri) {
|
|
98
|
+
return this._request("resources/read", { uri });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_request(method, params) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const id = ++requestId;
|
|
104
|
+
const timeout = setTimeout(() => {
|
|
105
|
+
this.pendingRequests.delete(id);
|
|
106
|
+
reject(new Error(`MCP request ${method} timed out`));
|
|
107
|
+
}, 30000);
|
|
108
|
+
|
|
109
|
+
this.pendingRequests.set(id, {
|
|
110
|
+
resolve: (result) => { clearTimeout(timeout); resolve(result); },
|
|
111
|
+
reject: (err) => { clearTimeout(timeout); reject(err); },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const message = JSON.stringify({
|
|
115
|
+
jsonrpc: JSONRPC_VERSION,
|
|
116
|
+
id,
|
|
117
|
+
method,
|
|
118
|
+
params,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
this.process.stdin.write(message + "\n");
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_notify(method, params) {
|
|
126
|
+
const message = JSON.stringify({
|
|
127
|
+
jsonrpc: JSONRPC_VERSION,
|
|
128
|
+
method,
|
|
129
|
+
params,
|
|
130
|
+
});
|
|
131
|
+
this.process.stdin.write(message + "\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_processBuffer() {
|
|
135
|
+
const lines = this._buffer.split("\n");
|
|
136
|
+
this._buffer = lines.pop() || "";
|
|
137
|
+
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
if (!line.trim()) continue;
|
|
140
|
+
try {
|
|
141
|
+
const msg = JSON.parse(line);
|
|
142
|
+
if (msg.id && this.pendingRequests.has(msg.id)) {
|
|
143
|
+
const { resolve, reject } = this.pendingRequests.get(msg.id);
|
|
144
|
+
this.pendingRequests.delete(msg.id);
|
|
145
|
+
if (msg.error) {
|
|
146
|
+
reject(new Error(msg.error.message || "MCP error"));
|
|
147
|
+
} else {
|
|
148
|
+
resolve(msg.result);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
getToolDefinitions() {
|
|
156
|
+
return this.tools.map(t => ({
|
|
157
|
+
name: `mcp_${this.name}_${t.name}`,
|
|
158
|
+
description: `[MCP:${this.name}] ${t.description || t.name}`,
|
|
159
|
+
parameters: t.inputSchema || { type: "object", properties: {} },
|
|
160
|
+
mcpServer: this.name,
|
|
161
|
+
mcpToolName: t.name,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
disconnect() {
|
|
166
|
+
if (this.process) {
|
|
167
|
+
try { this.process.kill(); } catch {}
|
|
168
|
+
this.process = null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* MCP Manager — manages multiple MCP server connections
|
|
175
|
+
*/
|
|
176
|
+
export class McpManager {
|
|
177
|
+
constructor() {
|
|
178
|
+
this.servers = new Map();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async addServer(name, config) {
|
|
182
|
+
const server = new McpServer(
|
|
183
|
+
name,
|
|
184
|
+
config.command,
|
|
185
|
+
config.args || [],
|
|
186
|
+
config.env || {}
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await server.connect();
|
|
191
|
+
this.servers.set(name, server);
|
|
192
|
+
log.success(`MCP server '${name}' connected (${server.tools.length} tools)`);
|
|
193
|
+
return server;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
log.warn(`MCP server '${name}' failed: ${err.message}`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
getAllToolDefinitions() {
|
|
201
|
+
const tools = [];
|
|
202
|
+
for (const server of this.servers.values()) {
|
|
203
|
+
tools.push(...server.getToolDefinitions());
|
|
204
|
+
}
|
|
205
|
+
return tools;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async callTool(fullName, args) {
|
|
209
|
+
// Parse mcp_{server}_{tool} format
|
|
210
|
+
const match = fullName.match(/^mcp_([^_]+)_(.+)$/);
|
|
211
|
+
if (!match) return { error: `Invalid MCP tool name: ${fullName}` };
|
|
212
|
+
|
|
213
|
+
const [, serverName, toolName] = match;
|
|
214
|
+
const server = this.servers.get(serverName);
|
|
215
|
+
if (!server) return { error: `MCP server '${serverName}' not connected` };
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const result = await server.callTool(toolName, args);
|
|
219
|
+
// Extract text content from MCP response
|
|
220
|
+
if (result.content) {
|
|
221
|
+
return { result: result.content.map(c => c.text || JSON.stringify(c)).join("\n") };
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { error: err.message };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
isMcpTool(name) {
|
|
230
|
+
return name.startsWith("mcp_");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
disconnectAll() {
|
|
234
|
+
for (const server of this.servers.values()) {
|
|
235
|
+
server.disconnect();
|
|
236
|
+
}
|
|
237
|
+
this.servers.clear();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Multi-agent orchestration — spawn child agents for subtasks
|
|
2
|
+
import { log } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SubAgent — a lightweight agent that runs a focused subtask
|
|
6
|
+
* with its own message history but shares the parent's tools and API
|
|
7
|
+
*/
|
|
8
|
+
export class SubAgent {
|
|
9
|
+
constructor(name, systemPrompt, options = {}) {
|
|
10
|
+
this.name = name;
|
|
11
|
+
this.systemPrompt = systemPrompt;
|
|
12
|
+
this.maxIterations = options.maxIterations || 10;
|
|
13
|
+
this.tools = options.tools || [];
|
|
14
|
+
this.messages = [{ role: "system", content: systemPrompt }];
|
|
15
|
+
this.status = "idle"; // idle, running, done, error
|
|
16
|
+
this.result = null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Agent orchestrator — manages parent + child agents
|
|
22
|
+
*/
|
|
23
|
+
export class AgentOrchestrator {
|
|
24
|
+
constructor(chatFn, executeTool) {
|
|
25
|
+
this.chatFn = chatFn; // async function(messages, tools) => response
|
|
26
|
+
this.executeTool = executeTool; // async function(name, args) => result
|
|
27
|
+
this.agents = new Map();
|
|
28
|
+
this.parentId = "main";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Spawn a child agent for a subtask
|
|
33
|
+
*/
|
|
34
|
+
createAgent(name, task, options = {}) {
|
|
35
|
+
const systemPrompt = `You are a sub-agent named "${name}" focused on a specific task.
|
|
36
|
+
Your task: ${task}
|
|
37
|
+
|
|
38
|
+
Complete this task using the available tools, then provide a concise summary of what you did and the results.
|
|
39
|
+
Be focused and efficient. Do not ask questions — make reasonable decisions.
|
|
40
|
+
Current working directory: ${process.cwd()}`;
|
|
41
|
+
|
|
42
|
+
const agent = new SubAgent(name, systemPrompt, {
|
|
43
|
+
maxIterations: options.maxIterations || 8,
|
|
44
|
+
tools: options.tools,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this.agents.set(name, agent);
|
|
48
|
+
return agent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run a child agent to completion
|
|
53
|
+
*/
|
|
54
|
+
async runAgent(name, userMessage, allTools, parseSSEStream) {
|
|
55
|
+
const agent = this.agents.get(name);
|
|
56
|
+
if (!agent) throw new Error(`Agent '${name}' not found`);
|
|
57
|
+
|
|
58
|
+
agent.status = "running";
|
|
59
|
+
agent.messages.push({ role: "user", content: userMessage });
|
|
60
|
+
|
|
61
|
+
log.dim(` [${name}] Starting subtask...`);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
for (let i = 0; i < agent.maxIterations; i++) {
|
|
65
|
+
const response = await this.chatFn(agent.messages, allTools);
|
|
66
|
+
|
|
67
|
+
let fullText = "";
|
|
68
|
+
const toolCalls = {};
|
|
69
|
+
|
|
70
|
+
for await (const chunk of parseSSEStream(response)) {
|
|
71
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
72
|
+
const finishReason = chunk.choices?.[0]?.finish_reason;
|
|
73
|
+
|
|
74
|
+
if (delta?.content) fullText += delta.content;
|
|
75
|
+
|
|
76
|
+
if (delta?.tool_calls) {
|
|
77
|
+
for (const tc of delta.tool_calls) {
|
|
78
|
+
const idx = tc.index;
|
|
79
|
+
if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id || "", name: "", arguments: "" };
|
|
80
|
+
if (tc.id) toolCalls[idx].id = tc.id;
|
|
81
|
+
if (tc.function?.name) toolCalls[idx].name = tc.function.name;
|
|
82
|
+
if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (finishReason) break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const toolCallList = Object.values(toolCalls);
|
|
90
|
+
|
|
91
|
+
if (toolCallList.length === 0) {
|
|
92
|
+
agent.messages.push({ role: "assistant", content: fullText });
|
|
93
|
+
agent.status = "done";
|
|
94
|
+
agent.result = fullText;
|
|
95
|
+
log.dim(` [${name}] Completed.`);
|
|
96
|
+
return fullText;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const assistantMsg = { role: "assistant", content: fullText || null };
|
|
100
|
+
assistantMsg.tool_calls = toolCallList.map(tc => ({
|
|
101
|
+
id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments },
|
|
102
|
+
}));
|
|
103
|
+
agent.messages.push(assistantMsg);
|
|
104
|
+
|
|
105
|
+
// Execute tools (in parallel)
|
|
106
|
+
const toolPromises = toolCallList.map(async (tc) => {
|
|
107
|
+
let input = {};
|
|
108
|
+
try { input = JSON.parse(tc.arguments); } catch {}
|
|
109
|
+
log.dim(` [${name}] → ${tc.name}`);
|
|
110
|
+
const result = await this.executeTool(tc.name, input);
|
|
111
|
+
return { id: tc.id, result };
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const toolResults = await Promise.all(toolPromises);
|
|
115
|
+
|
|
116
|
+
for (const { id, result } of toolResults) {
|
|
117
|
+
const resultStr = JSON.stringify(result);
|
|
118
|
+
const truncated = resultStr.length > 6000 ? resultStr.slice(0, 6000) + "...(truncated)" : resultStr;
|
|
119
|
+
agent.messages.push({ role: "tool", tool_call_id: id, content: truncated });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
agent.status = "done";
|
|
124
|
+
agent.result = "Reached maximum iterations";
|
|
125
|
+
return agent.result;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
agent.status = "error";
|
|
128
|
+
agent.result = err.message;
|
|
129
|
+
log.warn(` [${name}] Error: ${err.message}`);
|
|
130
|
+
return `Error: ${err.message}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run multiple agents in parallel
|
|
136
|
+
*/
|
|
137
|
+
async runParallel(tasks, allTools, parseSSEStream) {
|
|
138
|
+
const promises = tasks.map(({ name, task }) => {
|
|
139
|
+
this.createAgent(name, task);
|
|
140
|
+
return this.runAgent(name, task, allTools, parseSSEStream);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return Promise.all(promises);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getAgentStatus() {
|
|
147
|
+
const statuses = {};
|
|
148
|
+
for (const [name, agent] of this.agents) {
|
|
149
|
+
statuses[name] = { status: agent.status, result: agent.result?.slice(0, 200) };
|
|
150
|
+
}
|
|
151
|
+
return statuses;
|
|
152
|
+
}
|
|
153
|
+
}
|