@jpssff/vanor 0.1.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/README-cn.md +166 -0
- package/README.md +120 -0
- package/base/config.js +162 -0
- package/base/core/compaction.js +58 -0
- package/base/core/harness.js +246 -0
- package/base/core/loop.js +72 -0
- package/base/core/prompt.js +126 -0
- package/base/core/session.js +255 -0
- package/base/events.js +54 -0
- package/base/i18n/index.js +80 -0
- package/base/i18n/locales/en.js +254 -0
- package/base/i18n/locales/zh-CN.js +252 -0
- package/base/llm/index.js +119 -0
- package/base/llm/providers/anthropic.js +147 -0
- package/base/llm/providers/openai.js +155 -0
- package/base/llm/sse.js +27 -0
- package/base/llm/trace.js +64 -0
- package/base/logger.js +57 -0
- package/base/memory/index.js +139 -0
- package/base/security/index.js +77 -0
- package/base/skills/loader.js +297 -0
- package/base/test/cli.test.js +91 -0
- package/base/test/config.test.js +63 -0
- package/base/test/core.test.js +154 -0
- package/base/test/i18n.test.js +32 -0
- package/base/test/loop.test.js +97 -0
- package/base/test/memory.test.js +47 -0
- package/base/test/message.test.js +38 -0
- package/base/test/session.test.js +324 -0
- package/base/test/skills.test.js +236 -0
- package/base/test/statusbar.test.js +143 -0
- package/base/test/tools.test.js +127 -0
- package/base/test/trace.test.js +62 -0
- package/base/test/tui.test.js +242 -0
- package/base/test/utils.test.js +35 -0
- package/base/tools/builtin.js +221 -0
- package/base/tools/index.js +157 -0
- package/base/transport/cli.js +417 -0
- package/base/transport/message.js +81 -0
- package/base/transport/statusbar.js +117 -0
- package/base/transport/tui.js +397 -0
- package/base/utils.js +150 -0
- package/docs/TECH_DESIGN.md +544 -0
- package/index.js +175 -0
- package/package.json +33 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// 内置工具(MVP):read_file / write_file / edit_file / list_dir / exec。
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createI18n } from "../i18n/index.js";
|
|
7
|
+
import { ensureDir } from "../utils.js";
|
|
8
|
+
|
|
9
|
+
const MAX_OUTPUT = 100_000;
|
|
10
|
+
const defaultI18n = createI18n("en");
|
|
11
|
+
|
|
12
|
+
function ok(content) {
|
|
13
|
+
return { content: String(content) };
|
|
14
|
+
}
|
|
15
|
+
function fail(content) {
|
|
16
|
+
return { content: String(content), isError: true };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const readFile = {
|
|
20
|
+
name: "read_file",
|
|
21
|
+
description: "Read text content from a workspace file, optionally by line offset/limit.",
|
|
22
|
+
danger: "safe",
|
|
23
|
+
parameters: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
path: { type: "string", description: "Relative workspace path or absolute path" },
|
|
27
|
+
offset: { type: "integer", description: "Start line (1-based)" },
|
|
28
|
+
limit: { type: "integer", description: "Number of lines to read" },
|
|
29
|
+
},
|
|
30
|
+
required: ["path"],
|
|
31
|
+
},
|
|
32
|
+
handler(args, ctx) {
|
|
33
|
+
const r = ctx.security.checkPath(args.path, { mustExist: true });
|
|
34
|
+
if (!r.ok) return fail(r.reason);
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = fs.readFileSync(r.abs, "utf8");
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return fail(ctx.t("builtin.readFailed", { message: e.message }));
|
|
40
|
+
}
|
|
41
|
+
if (args.offset || args.limit) {
|
|
42
|
+
const lines = text.split("\n");
|
|
43
|
+
const start = Math.max(0, (args.offset || 1) - 1);
|
|
44
|
+
const end = args.limit ? start + args.limit : lines.length;
|
|
45
|
+
text = lines.slice(start, end).join("\n");
|
|
46
|
+
}
|
|
47
|
+
if (text.length > MAX_OUTPUT) {
|
|
48
|
+
text = text.slice(0, MAX_OUTPUT) + `\n${ctx.t("builtin.truncatedContent")}`;
|
|
49
|
+
}
|
|
50
|
+
return ok(text || ctx.t("builtin.emptyFile"));
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const writeFile = {
|
|
55
|
+
name: "write_file",
|
|
56
|
+
description: "Write or overwrite a workspace file and create parent directories automatically.",
|
|
57
|
+
danger: "confirm",
|
|
58
|
+
parameters: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
path: { type: "string" },
|
|
62
|
+
content: { type: "string" },
|
|
63
|
+
},
|
|
64
|
+
required: ["path", "content"],
|
|
65
|
+
},
|
|
66
|
+
handler(args, ctx) {
|
|
67
|
+
const r = ctx.security.checkPath(args.path);
|
|
68
|
+
if (!r.ok) return fail(r.reason);
|
|
69
|
+
try {
|
|
70
|
+
ensureDir(path.dirname(r.abs));
|
|
71
|
+
fs.writeFileSync(r.abs, args.content ?? "");
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return fail(ctx.t("builtin.writeFailed", { message: e.message }));
|
|
74
|
+
}
|
|
75
|
+
return ok(ctx.t("builtin.written", { path: args.path, bytes: Buffer.byteLength(args.content ?? "") }));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const editFile = {
|
|
80
|
+
name: "edit_file",
|
|
81
|
+
description: "Replace the unique occurrence of old_string with new_string in a file.",
|
|
82
|
+
danger: "confirm",
|
|
83
|
+
parameters: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
path: { type: "string" },
|
|
87
|
+
old_string: { type: "string" },
|
|
88
|
+
new_string: { type: "string" },
|
|
89
|
+
},
|
|
90
|
+
required: ["path", "old_string", "new_string"],
|
|
91
|
+
},
|
|
92
|
+
handler(args, ctx) {
|
|
93
|
+
const r = ctx.security.checkPath(args.path, { mustExist: true });
|
|
94
|
+
if (!r.ok) return fail(r.reason);
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = fs.readFileSync(r.abs, "utf8");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return fail(ctx.t("builtin.readFailed", { message: e.message }));
|
|
100
|
+
}
|
|
101
|
+
const parts = text.split(args.old_string);
|
|
102
|
+
if (parts.length === 1) return fail(ctx.t("builtin.oldStringNotFound"));
|
|
103
|
+
if (parts.length > 2) return fail(ctx.t("builtin.oldStringNotUnique", { count: parts.length - 1 }));
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(r.abs, parts.join(args.new_string));
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return fail(ctx.t("builtin.writeFailed", { message: e.message }));
|
|
108
|
+
}
|
|
109
|
+
return ok(ctx.t("builtin.edited", { path: args.path }));
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const listDir = {
|
|
114
|
+
name: "list_dir",
|
|
115
|
+
description: "List directory contents.",
|
|
116
|
+
danger: "safe",
|
|
117
|
+
parameters: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: { path: { type: "string" } },
|
|
120
|
+
required: ["path"],
|
|
121
|
+
},
|
|
122
|
+
handler(args, ctx) {
|
|
123
|
+
const r = ctx.security.checkPath(args.path, { mustExist: true, dir: true });
|
|
124
|
+
if (!r.ok) return fail(r.reason);
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = fs.readdirSync(r.abs, { withFileTypes: true });
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return fail(ctx.t("builtin.readFailed", { message: e.message }));
|
|
130
|
+
}
|
|
131
|
+
const lines = entries
|
|
132
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
133
|
+
.sort();
|
|
134
|
+
return ok(lines.length ? lines.join("\n") : ctx.t("builtin.emptyDir"));
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const exec = {
|
|
139
|
+
name: "exec",
|
|
140
|
+
description: "Run a shell command in the workspace and return its output. Subject to allowlist/denylist and approval.",
|
|
141
|
+
danger: "confirm",
|
|
142
|
+
exclusive: true,
|
|
143
|
+
parameters: {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: {
|
|
146
|
+
command: { type: "string" },
|
|
147
|
+
cwd: { type: "string", description: "Working directory, defaults to workspace root" },
|
|
148
|
+
timeout: { type: "integer", description: "Timeout in milliseconds, defaults to 60000" },
|
|
149
|
+
},
|
|
150
|
+
required: ["command"],
|
|
151
|
+
},
|
|
152
|
+
handler(args, ctx) {
|
|
153
|
+
let cwd = ctx.security.workspaceRoot;
|
|
154
|
+
if (args.cwd) {
|
|
155
|
+
const r = ctx.security.checkPath(args.cwd, { mustExist: true, dir: true });
|
|
156
|
+
if (!r.ok) return fail(r.reason);
|
|
157
|
+
cwd = r.abs;
|
|
158
|
+
}
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let child;
|
|
161
|
+
try {
|
|
162
|
+
child = spawn(args.command, { shell: true, cwd, signal: ctx.signal });
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return resolve(fail(ctx.t("builtin.spawnFailed", { message: e.message })));
|
|
165
|
+
}
|
|
166
|
+
let out = "";
|
|
167
|
+
let truncated = false;
|
|
168
|
+
const onData = (d) => {
|
|
169
|
+
if (out.length < MAX_OUTPUT) out += d.toString();
|
|
170
|
+
else truncated = true;
|
|
171
|
+
};
|
|
172
|
+
child.stdout?.on("data", onData);
|
|
173
|
+
child.stderr?.on("data", onData);
|
|
174
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), args.timeout || 60000);
|
|
175
|
+
child.on("error", (e) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (e.name === "AbortError") resolve(fail(ctx.t("builtin.commandAborted")));
|
|
178
|
+
else resolve(fail(ctx.t("builtin.execFailed", { message: e.message })));
|
|
179
|
+
});
|
|
180
|
+
child.on("close", (code) => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
const note = truncated ? `\n${ctx.t("builtin.outputTruncated")}` : "";
|
|
183
|
+
resolve({
|
|
184
|
+
content: `${out || ctx.t("builtin.noOutput")}${note}\n${ctx.t("builtin.exitCode", { code })}`,
|
|
185
|
+
isError: code !== 0,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function withParameters(tool, properties) {
|
|
193
|
+
return {
|
|
194
|
+
...tool,
|
|
195
|
+
parameters: {
|
|
196
|
+
...tool.parameters,
|
|
197
|
+
properties: {
|
|
198
|
+
...tool.parameters.properties,
|
|
199
|
+
...properties,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function builtinTools(i18n = defaultI18n) {
|
|
206
|
+
const { t } = i18n;
|
|
207
|
+
return [
|
|
208
|
+
withParameters({ ...readFile, description: t("builtin.readFileDescription") }, {
|
|
209
|
+
path: { ...readFile.parameters.properties.path, description: t("builtin.pathDescription") },
|
|
210
|
+
offset: { ...readFile.parameters.properties.offset, description: t("builtin.offsetDescription") },
|
|
211
|
+
limit: { ...readFile.parameters.properties.limit, description: t("builtin.limitDescription") },
|
|
212
|
+
}),
|
|
213
|
+
{ ...writeFile, description: t("builtin.writeFileDescription") },
|
|
214
|
+
{ ...editFile, description: t("builtin.editFileDescription") },
|
|
215
|
+
{ ...listDir, description: t("builtin.listDirDescription") },
|
|
216
|
+
withParameters({ ...exec, description: t("builtin.execDescription") }, {
|
|
217
|
+
cwd: { ...exec.parameters.properties.cwd, description: t("builtin.cwdDescription") },
|
|
218
|
+
timeout: { ...exec.parameters.properties.timeout, description: t("builtin.timeoutDescription") },
|
|
219
|
+
}),
|
|
220
|
+
];
|
|
221
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// 工具注册表、参数校验、审批与执行。
|
|
2
|
+
|
|
3
|
+
import { EVENTS } from "../events.js";
|
|
4
|
+
import { createI18n } from "../i18n/index.js";
|
|
5
|
+
import { validateSchema } from "../utils.js";
|
|
6
|
+
import { toolMessage } from "../transport/message.js";
|
|
7
|
+
import { builtinTools } from "./builtin.js";
|
|
8
|
+
|
|
9
|
+
const defaultI18n = createI18n("en");
|
|
10
|
+
|
|
11
|
+
export class ToolRegistry {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.tools = new Map();
|
|
14
|
+
}
|
|
15
|
+
register(tool) {
|
|
16
|
+
this.tools.set(tool.name, tool);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
registerAll(list) {
|
|
20
|
+
for (const t of list) this.register(t);
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
get(name) {
|
|
24
|
+
return this.tools.get(name);
|
|
25
|
+
}
|
|
26
|
+
has(name) {
|
|
27
|
+
return this.tools.has(name);
|
|
28
|
+
}
|
|
29
|
+
/** 返回供 LLM 使用的 schema 列表。 */
|
|
30
|
+
schemas() {
|
|
31
|
+
return [...this.tools.values()].map((t) => ({
|
|
32
|
+
name: t.name,
|
|
33
|
+
description: t.description,
|
|
34
|
+
parameters: t.parameters,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function defaultRegistry(i18n = defaultI18n) {
|
|
40
|
+
return new ToolRegistry().registerAll(builtinTools(i18n));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 构造工具执行上下文。
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {object} o.security createSecurity 的返回
|
|
47
|
+
* @param {object} o.logger
|
|
48
|
+
* @param {AbortSignal} [o.signal]
|
|
49
|
+
* @param {(msg:string)=>Promise<boolean>} [o.confirm] 交互式确认回调
|
|
50
|
+
*/
|
|
51
|
+
export function createToolContext({ security, logger, signal, confirm, i18n = defaultI18n }) {
|
|
52
|
+
return {
|
|
53
|
+
security,
|
|
54
|
+
logger,
|
|
55
|
+
signal,
|
|
56
|
+
confirm: confirm || (async () => false),
|
|
57
|
+
i18n,
|
|
58
|
+
t: i18n.t,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function describe(call, t) {
|
|
63
|
+
const a = call.arguments || {};
|
|
64
|
+
switch (call.name) {
|
|
65
|
+
case "exec":
|
|
66
|
+
return t("tools.describeExec", { command: a.command });
|
|
67
|
+
case "write_file":
|
|
68
|
+
return t("tools.describeWriteFile", { path: a.path });
|
|
69
|
+
case "edit_file":
|
|
70
|
+
return t("tools.describeEditFile", { path: a.path });
|
|
71
|
+
case "skills":
|
|
72
|
+
return t("tools.describeSkills", { action: a.action, name: a.name ? ` ${a.name}` : "" });
|
|
73
|
+
default:
|
|
74
|
+
return t("tools.describeGeneric", { name: call.name });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 审批决策:返回 true 放行,false 拒绝。 */
|
|
79
|
+
async function approve(tool, call, ctx) {
|
|
80
|
+
const danger = typeof tool.danger === "function" ? tool.danger(call.arguments || {}) : tool.danger;
|
|
81
|
+
if (danger === "safe") return true;
|
|
82
|
+
const decision =
|
|
83
|
+
call.name === "exec"
|
|
84
|
+
? ctx.security.checkCommand(String(call.arguments?.command || ""))
|
|
85
|
+
: ctx.security.approvalMode();
|
|
86
|
+
if (decision === "allow") return true;
|
|
87
|
+
if (decision === "deny") return false;
|
|
88
|
+
return ctx.confirm(describe(call, ctx.t));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 执行单个工具调用,始终返回一条 tool 消息。 */
|
|
92
|
+
export async function runToolCall(call, registry, ctx) {
|
|
93
|
+
const tool = registry.get(call.name);
|
|
94
|
+
if (!tool) {
|
|
95
|
+
return toolMessage({
|
|
96
|
+
toolCallId: call.id,
|
|
97
|
+
name: call.name,
|
|
98
|
+
content: ctx.t("tools.unknownTool", { name: call.name }),
|
|
99
|
+
isError: true,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const errs = validateSchema(tool.parameters, call.arguments || {}, "", ctx.t);
|
|
104
|
+
if (errs.length) {
|
|
105
|
+
return toolMessage({
|
|
106
|
+
toolCallId: call.id,
|
|
107
|
+
name: call.name,
|
|
108
|
+
content: ctx.t("tools.invalidArgs", { errors: errs.join("; ") }),
|
|
109
|
+
isError: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const allowed = await approve(tool, call, ctx);
|
|
114
|
+
if (!allowed) {
|
|
115
|
+
ctx.logger.warn(EVENTS.tool.denied, { name: call.name });
|
|
116
|
+
return toolMessage({
|
|
117
|
+
toolCallId: call.id,
|
|
118
|
+
name: call.name,
|
|
119
|
+
content: ctx.t("tools.denied"),
|
|
120
|
+
isError: true,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
ctx.logger.info(EVENTS.tool.call, { name: call.name, id: call.id });
|
|
125
|
+
try {
|
|
126
|
+
const res = await tool.handler(call.arguments || {}, ctx);
|
|
127
|
+
ctx.logger.info(EVENTS.tool.result, { name: call.name, isError: !!res.isError });
|
|
128
|
+
return toolMessage({
|
|
129
|
+
toolCallId: call.id,
|
|
130
|
+
name: call.name,
|
|
131
|
+
content: res.content,
|
|
132
|
+
isError: !!res.isError,
|
|
133
|
+
});
|
|
134
|
+
} catch (e) {
|
|
135
|
+
ctx.logger.error(EVENTS.tool.error, { name: call.name, error: e.message });
|
|
136
|
+
return toolMessage({
|
|
137
|
+
toolCallId: call.id,
|
|
138
|
+
name: call.name,
|
|
139
|
+
content: ctx.t("tools.exception", { message: e.message }),
|
|
140
|
+
isError: true,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 顺序执行多个工具调用(MVP 串行,保证审批与输出有序;v2 再做并行)。
|
|
147
|
+
* @param {(call,result)=>void} [onResult] 每条结果回调(用于 UI 展示)
|
|
148
|
+
*/
|
|
149
|
+
export async function runToolCalls(toolCalls, registry, ctx, onResult) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const call of toolCalls) {
|
|
152
|
+
const msg = await runToolCall(call, registry, ctx);
|
|
153
|
+
if (onResult) onResult(call, msg);
|
|
154
|
+
out.push(msg);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|