@jpssff/vanor 0.1.0 → 0.1.1
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 +1 -1
- package/README.md +1 -1
- package/base/config.js +3 -3
- package/base/core/harness.js +18 -0
- package/base/core/loop.js +9 -3
- package/base/core/prompt.js +2 -2
- package/base/core/vanor_tool.js +178 -0
- package/base/i18n/locales/en.js +38 -1
- package/base/i18n/locales/zh-CN.js +38 -1
- package/base/memory/index.js +6 -5
- package/base/test/core.test.js +11 -1
- package/base/test/loop.test.js +90 -1
- package/base/test/memory.test.js +10 -1
- package/base/test/session.test.js +44 -0
- package/base/test/tools.test.js +61 -1
- package/base/test/tui.test.js +69 -0
- package/base/tools/builtin.js +39 -6
- package/base/tools/index.js +13 -0
- package/base/transport/cli.js +1 -1
- package/base/transport/tui.js +78 -14
- package/docs/TECH_DESIGN.md +6 -3
- package/package.json +1 -1
package/README-cn.md
CHANGED
|
@@ -135,7 +135,7 @@ Vanor 使用三层记忆:
|
|
|
135
135
|
| 层级 | 说明 |
|
|
136
136
|
|------|------|
|
|
137
137
|
| 短期 | 当前会话消息,存于 `sessions/`;压缩后内存只保留摘要与最近消息,但磁盘保留完整历史 |
|
|
138
|
-
| 长期 | `memory/MEMORY.md` 与 `memory/USER.md
|
|
138
|
+
| 长期 | `memory/MEMORY.md` 与 `memory/USER.md`,记录环境事实和用户偏好;每个主文件限制 10KB,过多时用索引指向 `MEMORY-xxx.md` / `USER-xxx.md` |
|
|
139
139
|
| 工作 | `memory/working/`,保存任务中的计划和中间状态 |
|
|
140
140
|
|
|
141
141
|
会话恢复优先读取 `state.json` 中当前工作区的最近 session id,避免 session 文件过多时启动扫描变慢。若索引失效,再回退扫描 `sessions/`。
|
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ Vanor asks before potentially risky tool actions by default. `/auto-run` only ch
|
|
|
106
106
|
~/.vanor/config.json provider, model, security, logging config
|
|
107
107
|
~/.vanor/state.json latest session index per workspace
|
|
108
108
|
~/.vanor/sessions/ JSONL session history
|
|
109
|
-
~/.vanor/memory/ long-term and working memory
|
|
109
|
+
~/.vanor/memory/ long-term and working memory; MEMORY.md / USER.md are 10KB index files
|
|
110
110
|
~/.vanor/skills/ user skills
|
|
111
111
|
~/.vanor/logs/ JSONL logs
|
|
112
112
|
```
|
package/base/config.js
CHANGED
|
@@ -15,13 +15,13 @@ export const DEFAULT_CONFIG = {
|
|
|
15
15
|
fallback: [],
|
|
16
16
|
},
|
|
17
17
|
agent: {
|
|
18
|
-
maxIterations:
|
|
18
|
+
maxIterations: 200,
|
|
19
19
|
thinking: "off",
|
|
20
20
|
contextWindow: 256000,
|
|
21
21
|
},
|
|
22
22
|
memory: {
|
|
23
|
-
memoryMaxChars:
|
|
24
|
-
userMaxChars:
|
|
23
|
+
memoryMaxChars: 10240,
|
|
24
|
+
userMaxChars: 10240,
|
|
25
25
|
compactThreshold: 0.75,
|
|
26
26
|
},
|
|
27
27
|
security: {
|
package/base/core/harness.js
CHANGED
|
@@ -14,6 +14,7 @@ import { buildSystemPrompt } from "./prompt.js";
|
|
|
14
14
|
import { latestSession, saveLatestSessionId, Session } from "./session.js";
|
|
15
15
|
import { runLoop } from "./loop.js";
|
|
16
16
|
import { compact } from "./compaction.js";
|
|
17
|
+
import { createVanorTool } from "./vanor_tool.js";
|
|
17
18
|
|
|
18
19
|
export function createHarness({ config, paths, logger, channel = "cli", sessionId, restoreLatest = false }) {
|
|
19
20
|
let configState = config;
|
|
@@ -50,9 +51,25 @@ export function createHarness({ config, paths, logger, channel = "cli", sessionI
|
|
|
50
51
|
registry = defaultRegistry(i18n);
|
|
51
52
|
registry.register(memory.tool);
|
|
52
53
|
registry.register(createSkillsTool(paths, logger, { getSkills: () => skills, reloadSkills, getI18n: () => i18n }));
|
|
54
|
+
registry.register(createVanorTool(vanorRuntime(), i18n));
|
|
53
55
|
reloadSkills();
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
function vanorRuntime() {
|
|
59
|
+
return {
|
|
60
|
+
getModel: () => model,
|
|
61
|
+
setModel,
|
|
62
|
+
getConfig: () => configState,
|
|
63
|
+
getSession: () => session,
|
|
64
|
+
getSecurity: () => security,
|
|
65
|
+
getI18n: () => i18n,
|
|
66
|
+
getSkills: () => skills,
|
|
67
|
+
reloadSkills,
|
|
68
|
+
reloadConfig,
|
|
69
|
+
setLanguage,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
rebuildRuntime(configState);
|
|
57
74
|
|
|
58
75
|
let model = configState.llm.defaultModel;
|
|
@@ -155,6 +172,7 @@ export function createHarness({ config, paths, logger, channel = "cli", sessionI
|
|
|
155
172
|
toolCtx,
|
|
156
173
|
config: configState,
|
|
157
174
|
model,
|
|
175
|
+
getModel: () => model,
|
|
158
176
|
tools: registry.schemas(),
|
|
159
177
|
buildSystem,
|
|
160
178
|
logger,
|
package/base/core/loop.js
CHANGED
|
@@ -15,11 +15,12 @@ import { shouldCompact, compact } from "./compaction.js";
|
|
|
15
15
|
* @returns {Promise<{assistant?, aborted?, error?, maxIterations?}>}
|
|
16
16
|
*/
|
|
17
17
|
export async function runLoop(session, deps, handlers = {}, signal) {
|
|
18
|
-
const { llm, registry, toolCtx, config,
|
|
18
|
+
const { llm, registry, toolCtx, config, tools, buildSystem, logger } = deps;
|
|
19
19
|
const maxIter = config.agent?.maxIterations ?? 25;
|
|
20
|
-
logger.info(EVENTS.agent.turnStart, { model, sessionId: session.id });
|
|
20
|
+
logger.info(EVENTS.agent.turnStart, { model: deps.getModel?.() || deps.model, sessionId: session.id });
|
|
21
21
|
|
|
22
22
|
for (let i = 0; i < maxIter; i++) {
|
|
23
|
+
const model = deps.getModel?.() || deps.model;
|
|
23
24
|
if (signal?.aborted) {
|
|
24
25
|
logger.info(EVENTS.agent.turnAborted, { iteration: i });
|
|
25
26
|
return { aborted: true };
|
|
@@ -64,7 +65,12 @@ export async function runLoop(session, deps, handlers = {}, signal) {
|
|
|
64
65
|
const results = await runToolCalls(toolCalls, registry, toolCtx, handlers.onToolResult);
|
|
65
66
|
for (const r of results) session.addMessage(r);
|
|
66
67
|
|
|
67
|
-
if (
|
|
68
|
+
if (signal?.aborted) {
|
|
69
|
+
logger.info(EVENTS.agent.turnAborted, { iteration: i, phase: "tools" });
|
|
70
|
+
return { aborted: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (shouldCompact(session, config)) await compact(session, { ...deps, model: deps.getModel?.() || deps.model });
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
logger.warn(EVENTS.agent.turnMaxIterations, { maxIter });
|
package/base/core/prompt.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { createI18n } from "../i18n/index.js";
|
|
5
|
-
import { nowIso } from "../utils.js";
|
|
6
5
|
import { renderSkills } from "../skills/loader.js";
|
|
7
6
|
import { systemMessage } from "../transport/message.js";
|
|
8
7
|
|
|
@@ -34,7 +33,7 @@ function runtimeEnv(workspaceRoot, t) {
|
|
|
34
33
|
t("prompt.node", { version: process.version }),
|
|
35
34
|
t("prompt.shell", { shell }),
|
|
36
35
|
t("prompt.workspace", { workspace: workspaceRoot }),
|
|
37
|
-
t("prompt.
|
|
36
|
+
t("prompt.timezone", { timezone: tz, offset: tzOffset() }),
|
|
38
37
|
].join("\n");
|
|
39
38
|
}
|
|
40
39
|
|
|
@@ -90,6 +89,7 @@ export function buildSystemPrompt({ memory, skills, summary, workspaceRoot, chan
|
|
|
90
89
|
const parts = [t("prompt.persona"), t("prompt.language")];
|
|
91
90
|
parts.push(runtimeEnv(workspaceRoot, t));
|
|
92
91
|
if (instance) parts.push(vanorContext(instance, t));
|
|
92
|
+
parts.push(t("prompt.memoryPolicy"));
|
|
93
93
|
const guide = channel === "cli" ? t("prompt.channelCli") : t("prompt.channelDefault");
|
|
94
94
|
parts.push(t("prompt.communication", { guide }));
|
|
95
95
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createI18n } from "../i18n/index.js";
|
|
2
|
+
import { summarizeSkills } from "../skills/loader.js";
|
|
3
|
+
import { listUserMessages } from "./session.js";
|
|
4
|
+
|
|
5
|
+
const defaultI18n = createI18n("en");
|
|
6
|
+
|
|
7
|
+
function ok(content) {
|
|
8
|
+
return { content: String(content) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fail(content) {
|
|
12
|
+
return { content: String(content), isError: true };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseCommand(input) {
|
|
16
|
+
const raw = String(input || "").trim();
|
|
17
|
+
if (!raw) return { raw, cmd: "", arg: "" };
|
|
18
|
+
const slash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
19
|
+
const [cmd, ...rest] = slash.slice(1).split(/\s+/);
|
|
20
|
+
return { raw: slash, cmd, arg: rest.join(" ").trim() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function availableModels(config, currentModel = "") {
|
|
24
|
+
const out = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const add = (model) => {
|
|
27
|
+
const value = String(model || "").trim();
|
|
28
|
+
if (!value || seen.has(value)) return;
|
|
29
|
+
seen.add(value);
|
|
30
|
+
out.push(value);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
add(currentModel);
|
|
34
|
+
add(config.llm?.defaultModel);
|
|
35
|
+
for (const model of config.llm?.fallback || []) add(model);
|
|
36
|
+
|
|
37
|
+
for (const [providerName, provider] of Object.entries(config.llm?.providers || {})) {
|
|
38
|
+
for (const model of provider.models || []) {
|
|
39
|
+
const id = typeof model === "string" ? model : model?.id || model?.name || "";
|
|
40
|
+
if (!id) continue;
|
|
41
|
+
add(id.startsWith(`${providerName}/`) ? id : `${providerName}/${id}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function searchModels(models, query) {
|
|
48
|
+
const q = String(query || "").trim().toLowerCase();
|
|
49
|
+
if (!q) return models;
|
|
50
|
+
return models.filter((model) => model.toLowerCase().includes(q));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatUserMessage(message) {
|
|
54
|
+
const content = String(message.content || "").replace(/\s+/g, " ").trim();
|
|
55
|
+
return `${String(message.time || "").slice(0, 19)} ${content}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createVanorTool(runtime, i18n = defaultI18n) {
|
|
59
|
+
const currentI18n = () => runtime.getI18n?.() || i18n;
|
|
60
|
+
const t = (key, vars) => currentI18n().t(key, vars);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: "vanor",
|
|
64
|
+
description: t("vanor.toolDescription"),
|
|
65
|
+
danger(args = {}) {
|
|
66
|
+
const { cmd, arg } = parseCommand(args.command);
|
|
67
|
+
if (cmd === "auto-run" && arg !== "off") return "confirm";
|
|
68
|
+
return "safe";
|
|
69
|
+
},
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
command: { type: "string", description: t("vanor.paramCommand") },
|
|
74
|
+
},
|
|
75
|
+
required: ["command"],
|
|
76
|
+
},
|
|
77
|
+
async handler(args) {
|
|
78
|
+
const { raw, cmd, arg } = parseCommand(args.command);
|
|
79
|
+
if (!cmd) return fail(t("vanor.emptyCommand"));
|
|
80
|
+
|
|
81
|
+
switch (cmd) {
|
|
82
|
+
case "help":
|
|
83
|
+
return ok(t("vanor.help"));
|
|
84
|
+
case "model":
|
|
85
|
+
return runModel(arg);
|
|
86
|
+
case "language":
|
|
87
|
+
return runLanguage(arg);
|
|
88
|
+
case "reload":
|
|
89
|
+
return runReload();
|
|
90
|
+
case "auto-run":
|
|
91
|
+
return runAutoRun(arg);
|
|
92
|
+
case "usage":
|
|
93
|
+
return runUsage();
|
|
94
|
+
case "messages":
|
|
95
|
+
return runMessages();
|
|
96
|
+
case "skills":
|
|
97
|
+
return runSkills();
|
|
98
|
+
case "exit":
|
|
99
|
+
case "quit":
|
|
100
|
+
case "retry":
|
|
101
|
+
case "new":
|
|
102
|
+
case "reset":
|
|
103
|
+
case "compact":
|
|
104
|
+
return fail(t("vanor.unsupportedCommand", { command: raw }));
|
|
105
|
+
default:
|
|
106
|
+
return fail(t("vanor.unknownCommand", { command: raw }));
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function runModel(arg) {
|
|
112
|
+
if (!arg) return ok(t("vanor.currentModel", { model: runtime.getModel() }));
|
|
113
|
+
const models = availableModels(runtime.getConfig(), runtime.getModel());
|
|
114
|
+
const matches = searchModels(models, arg);
|
|
115
|
+
const exact = matches.find((model) => model.toLowerCase() === arg.toLowerCase());
|
|
116
|
+
const target = exact || (matches.length === 1 ? matches[0] : "");
|
|
117
|
+
if (!target && matches.length > 1) {
|
|
118
|
+
return ok(t("vanor.modelMatches", { count: matches.length, models: matches.map((m, i) => `${i + 1}. ${m}`).join("\n") }));
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const next = runtime.setModel(target || arg);
|
|
122
|
+
return ok(t("vanor.modelChanged", { model: next }));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
const suffix = models.length ? `\n${t("vanor.availableModels", { models: models.map((m) => `- ${m}`).join("\n") })}` : "";
|
|
125
|
+
return fail(t("vanor.modelSwitchFailed", { message: e.message }) + suffix);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function runLanguage(arg) {
|
|
130
|
+
const lang = currentI18n();
|
|
131
|
+
if (!arg) return ok(t("vanor.languageCurrent", { language: lang.language, configured: lang.configured || "auto", source: lang.source || "" }));
|
|
132
|
+
try {
|
|
133
|
+
const next = runtime.setLanguage(arg);
|
|
134
|
+
return ok(next.t("vanor.languageChanged", { language: next.language, configured: next.configured || "auto", source: next.source || "" }));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return fail(e.message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runReload() {
|
|
141
|
+
const r = runtime.reloadConfig({ force: true });
|
|
142
|
+
if (r.error) return fail(t("vanor.reloadFailed", { error: r.error }));
|
|
143
|
+
if (r.reloaded) return ok(t("vanor.reloadDone", { model: runtime.getModel() }));
|
|
144
|
+
return ok(t("vanor.reloadUnchanged"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function runAutoRun(arg) {
|
|
148
|
+
if (arg === "off") {
|
|
149
|
+
runtime.getSecurity().setApproval(runtime.getSecurity().initialApproval);
|
|
150
|
+
return ok(t("vanor.approvalRestored", { approval: runtime.getSecurity().approval }));
|
|
151
|
+
}
|
|
152
|
+
if (arg && arg !== "on") return fail(t("vanor.autoRunUsage"));
|
|
153
|
+
runtime.getSecurity().setApproval("auto");
|
|
154
|
+
return ok(t("vanor.autoRunEnabled"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function runUsage() {
|
|
158
|
+
const usage = runtime.getSession().usage;
|
|
159
|
+
return ok(t("vanor.usage", { prompt: usage.promptTokens, completion: usage.completionTokens, total: usage.totalTokens }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function runMessages() {
|
|
163
|
+
const session = runtime.getSession();
|
|
164
|
+
const messages = listUserMessages(session.paths, session.id);
|
|
165
|
+
if (!messages.length) return ok(t("vanor.messagesNone"));
|
|
166
|
+
return ok([t("vanor.messagesHeader"), ...messages.map(formatUserMessage)].join("\n"));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function runSkills() {
|
|
170
|
+
runtime.reloadSkills();
|
|
171
|
+
const skills = runtime.getSkills();
|
|
172
|
+
const summary = summarizeSkills(skills);
|
|
173
|
+
const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
|
|
174
|
+
lines.push("", t("vanor.skillsTotal", { count: summary.total }));
|
|
175
|
+
for (const item of summary.byDir) lines.push(`- ${item.dir}: ${item.count}`);
|
|
176
|
+
return ok(lines.join("\n"));
|
|
177
|
+
}
|
|
178
|
+
}
|
package/base/i18n/locales/en.js
CHANGED
|
@@ -176,11 +176,13 @@ export default {
|
|
|
176
176
|
describeWriteFile: "Write file: {path}",
|
|
177
177
|
describeEditFile: "Edit file: {path}",
|
|
178
178
|
describeSkills: "Manage skills: {action}{name}",
|
|
179
|
+
describeVanor: "Run Vanor command: {command}",
|
|
179
180
|
describeGeneric: "Call tool {name}",
|
|
180
181
|
unknownTool: "Unknown tool: {name}",
|
|
181
182
|
invalidArgs: "Invalid arguments: {errors}",
|
|
182
183
|
denied: "The operation was denied by the user or policy",
|
|
183
184
|
exception: "Tool exception: {message}",
|
|
185
|
+
aborted: "Tool execution was interrupted",
|
|
184
186
|
},
|
|
185
187
|
builtin: {
|
|
186
188
|
readFileDescription: "Read text content from a workspace file, optionally by line offset/limit.",
|
|
@@ -209,6 +211,34 @@ export default {
|
|
|
209
211
|
noOutput: "(no output)",
|
|
210
212
|
exitCode: "[exit code {code}]",
|
|
211
213
|
},
|
|
214
|
+
vanor: {
|
|
215
|
+
toolDescription:
|
|
216
|
+
"Run Vanor slash commands without leaving the conversation. Supports runtime commands such as /model, /language, /reload, /auto-run, /usage, /messages, /skills, and /help.",
|
|
217
|
+
paramCommand: 'Slash command to run, for example "/model deepseek", "/language zh-CN", "/reload", or "/auto-run off".',
|
|
218
|
+
emptyCommand: "command is required",
|
|
219
|
+
help:
|
|
220
|
+
"Supported Vanor commands: /model [query], /language [auto|en|zh-CN], /reload, /auto-run [on|off], /usage, /messages, /skills, /help.\n" +
|
|
221
|
+
"Process or session control commands such as /exit, /quit, /retry, /new, /reset, and /compact must be run directly in the CLI.",
|
|
222
|
+
unsupportedCommand: "{command} is not supported through the vanor tool. Run it directly in the CLI.",
|
|
223
|
+
unknownCommand: "Unknown Vanor command: {command}",
|
|
224
|
+
currentModel: "Current model: {model}",
|
|
225
|
+
modelChanged: "Switched model: {model}",
|
|
226
|
+
modelMatches: "Found {count} matching models. Please choose one explicitly:\n{models}",
|
|
227
|
+
availableModels: "Available models:\n{models}",
|
|
228
|
+
modelSwitchFailed: "Switch failed: {message}",
|
|
229
|
+
languageCurrent: "Language: {language} (configured: {configured}, source: {source})",
|
|
230
|
+
languageChanged: "Language changed to {language} (configured: {configured}, source: {source})",
|
|
231
|
+
reloadFailed: "Config reload failed: {error}",
|
|
232
|
+
reloadDone: "Config reloaded. Current model: {model}",
|
|
233
|
+
reloadUnchanged: "Config unchanged.",
|
|
234
|
+
approvalRestored: "Restored approval mode: {approval}",
|
|
235
|
+
autoRunEnabled: "Auto-run enabled for this session. Denylisted dangerous commands are still blocked.",
|
|
236
|
+
autoRunUsage: "Usage: /auto-run [on|off]",
|
|
237
|
+
usage: "prompt {prompt} / completion {completion} / total {total}",
|
|
238
|
+
messagesNone: "(no user messages)",
|
|
239
|
+
messagesHeader: "Time User message",
|
|
240
|
+
skillsTotal: "Total: {count} skills",
|
|
241
|
+
},
|
|
212
242
|
prompt: {
|
|
213
243
|
persona:
|
|
214
244
|
"You are Vanor, a general-purpose agent by Wanyou Intelligence.\n" +
|
|
@@ -223,7 +253,7 @@ export default {
|
|
|
223
253
|
node: "- Runtime: Node.js {version}; Vanor Agent is built on Node.js and can call node / npm via exec",
|
|
224
254
|
shell: "- Default shell: {shell}",
|
|
225
255
|
workspace: "- Workspace: {workspace}",
|
|
226
|
-
|
|
256
|
+
timezone: "- Time zone: {timezone} ({offset})",
|
|
227
257
|
contextTitle: "Vanor instance context:",
|
|
228
258
|
currentModel: "- Current model: {model}",
|
|
229
259
|
provider: "- Current provider: {provider}",
|
|
@@ -241,6 +271,13 @@ export default {
|
|
|
241
271
|
skillsCount: "- Loaded skills: {count}",
|
|
242
272
|
answerContext: "- If the user asks about model, config file, session, directories, language, or approval mode, answer directly from this context.",
|
|
243
273
|
noSecrets: "- Do not reveal API keys, Authorization headers, cookies, tokens, private keys, or other secrets. Only say whether a secret is configured or redacted.",
|
|
274
|
+
memoryPolicy:
|
|
275
|
+
"Long-term memory policy:\n" +
|
|
276
|
+
"- Treat ~/.vanor/memory/MEMORY.md and ~/.vanor/memory/USER.md as the most important memory files; their current snapshots are included in every interaction when present.\n" +
|
|
277
|
+
"- At the end of each task, decide whether new stable facts, project conventions, user preferences, or reusable lessons should be saved to MEMORY.md or USER.md.\n" +
|
|
278
|
+
"- Each primary file is limited to 10KB. Keep entries concise and maintain them as durable indexed summaries.\n" +
|
|
279
|
+
"- If more memory is needed, keep an index entry in MEMORY.md or USER.md and store details in secondary files such as ~/.vanor/memory/MEMORY-xxx.md or ~/.vanor/memory/USER-xxx.md.\n" +
|
|
280
|
+
"- Use a two-level lookup: first check the primary index, then read the referenced secondary file only when the current task needs that detail.",
|
|
244
281
|
terminal:
|
|
245
282
|
"Terminal capabilities: TERM={term}, viewport about {cols} columns x {rows} rows, {color}. " +
|
|
246
283
|
"Fit output to the terminal: keep lines under {cols} columns when practical; avoid code blocks / tables wider than the viewport; {ansi}split long content into sections and avoid flooding the screen.",
|
|
@@ -174,11 +174,13 @@ export default {
|
|
|
174
174
|
describeWriteFile: "写入文件:{path}",
|
|
175
175
|
describeEditFile: "编辑文件:{path}",
|
|
176
176
|
describeSkills: "管理技能:{action}{name}",
|
|
177
|
+
describeVanor: "执行 Vanor 命令:{command}",
|
|
177
178
|
describeGeneric: "调用工具 {name}",
|
|
178
179
|
unknownTool: "未知工具:{name}",
|
|
179
180
|
invalidArgs: "参数错误:{errors}",
|
|
180
181
|
denied: "用户或策略拒绝了该操作",
|
|
181
182
|
exception: "工具异常:{message}",
|
|
183
|
+
aborted: "工具执行已中断",
|
|
182
184
|
},
|
|
183
185
|
builtin: {
|
|
184
186
|
readFileDescription: "读取工作区内文件的文本内容,可选按行 offset/limit 截取。",
|
|
@@ -207,6 +209,34 @@ export default {
|
|
|
207
209
|
noOutput: "(无输出)",
|
|
208
210
|
exitCode: "[退出码 {code}]",
|
|
209
211
|
},
|
|
212
|
+
vanor: {
|
|
213
|
+
toolDescription:
|
|
214
|
+
"在对话中执行 Vanor slash 命令,无需离开当前对话。支持 /model、/language、/reload、/auto-run、/usage、/messages、/skills、/help 等运行时命令。",
|
|
215
|
+
paramCommand: '要执行的 slash 命令,例如 "/model deepseek"、"/language zh-CN"、"/reload" 或 "/auto-run off"。',
|
|
216
|
+
emptyCommand: "command 不能为空",
|
|
217
|
+
help:
|
|
218
|
+
"支持的 Vanor 命令:/model [query]、/language [auto|en|zh-CN]、/reload、/auto-run [on|off]、/usage、/messages、/skills、/help。\n" +
|
|
219
|
+
"进程或会话控制命令(如 /exit、/quit、/retry、/new、/reset、/compact)需在 CLI 中直接执行。",
|
|
220
|
+
unsupportedCommand: "{command} 不支持通过 vanor 工具执行,请在 CLI 中直接运行。",
|
|
221
|
+
unknownCommand: "未知 Vanor 命令:{command}",
|
|
222
|
+
currentModel: "当前模型:{model}",
|
|
223
|
+
modelChanged: "已切换模型:{model}",
|
|
224
|
+
modelMatches: "匹配到 {count} 个模型,请明确选择其中一个:\n{models}",
|
|
225
|
+
availableModels: "可用模型:\n{models}",
|
|
226
|
+
modelSwitchFailed: "切换失败:{message}",
|
|
227
|
+
languageCurrent: "语言:{language}(配置:{configured},来源:{source})",
|
|
228
|
+
languageChanged: "语言已切换为 {language}(配置:{configured},来源:{source})",
|
|
229
|
+
reloadFailed: "配置重载失败:{error}",
|
|
230
|
+
reloadDone: "配置已重载。当前模型:{model}",
|
|
231
|
+
reloadUnchanged: "配置未变化。",
|
|
232
|
+
approvalRestored: "已恢复审批模式:{approval}",
|
|
233
|
+
autoRunEnabled: "已开启自动执行:本次对话不再逐次确认操作(denylist 危险命令仍会拦截)。",
|
|
234
|
+
autoRunUsage: "用法:/auto-run [on|off]",
|
|
235
|
+
usage: "prompt {prompt} / completion {completion} / total {total}",
|
|
236
|
+
messagesNone: "(暂无用户消息)",
|
|
237
|
+
messagesHeader: "时间 用户内容",
|
|
238
|
+
skillsTotal: "总计:{count} 个技能",
|
|
239
|
+
},
|
|
210
240
|
prompt: {
|
|
211
241
|
persona:
|
|
212
242
|
"你是 Vanor(万佑),万佑智算的通用智能体。\n" +
|
|
@@ -221,7 +251,7 @@ export default {
|
|
|
221
251
|
node: "- 运行时:Node.js {version};Vanor Agent 基于 Node.js 构建,可通过 exec 调用 node / npm 等工具",
|
|
222
252
|
shell: "- 默认 Shell:{shell}",
|
|
223
253
|
workspace: "- 工作区:{workspace}",
|
|
224
|
-
|
|
254
|
+
timezone: "- 时区:{timezone}({offset})",
|
|
225
255
|
contextTitle: "Vanor 实例上下文:",
|
|
226
256
|
currentModel: "- 当前模型:{model}",
|
|
227
257
|
provider: "- 当前 provider:{provider}",
|
|
@@ -239,6 +269,13 @@ export default {
|
|
|
239
269
|
skillsCount: "- 已加载技能数:{count}",
|
|
240
270
|
answerContext: "- 如果用户询问模型、配置文件、会话、目录、语言或审批模式,可直接依据本上下文回答。",
|
|
241
271
|
noSecrets: "- 不要输出 API Key、Authorization、Cookie、token、私钥等敏感配置;涉及密钥时只说明是否已配置或已脱敏。",
|
|
272
|
+
memoryPolicy:
|
|
273
|
+
"长期记忆策略:\n" +
|
|
274
|
+
"- 将 ~/.vanor/memory/MEMORY.md 与 ~/.vanor/memory/USER.md 视为最重要的记忆文件;只要存在内容,每次交互都会携带其当前快照。\n" +
|
|
275
|
+
"- 每次任务完成后,判断是否需要把新的稳定事实、项目约定、用户偏好或可复用经验写入 MEMORY.md 或 USER.md。\n" +
|
|
276
|
+
"- 每个主文件大小限制为 10KB。条目应简洁、稳定,并优先维护为可持久使用的索引摘要。\n" +
|
|
277
|
+
"- 当记忆信息过多时,在 MEMORY.md 或 USER.md 中保留索引条目,把详细内容保存到 ~/.vanor/memory/MEMORY-xxx.md 或 ~/.vanor/memory/USER-xxx.md。\n" +
|
|
278
|
+
"- 采用二级查找:先查看主文件索引,只有当前任务需要相关细节时,再读取索引指向的二级文件。",
|
|
242
279
|
terminal:
|
|
243
280
|
"终端能力:TERM={term},可视区约 {cols} 列 × {rows} 行,{color}。" +
|
|
244
281
|
"输出请适应终端:单行尽量不超过 {cols} 列,代码块 / 表格不要超出宽度;{ansi}较长内容分段,避免一次性刷屏。",
|
package/base/memory/index.js
CHANGED
|
@@ -12,8 +12,8 @@ export function createMemory(paths, config, logger) {
|
|
|
12
12
|
const memFile = path.join(paths.memory, "MEMORY.md");
|
|
13
13
|
const userFile = path.join(paths.memory, "USER.md");
|
|
14
14
|
const limits = {
|
|
15
|
-
memory: config.memory?.memoryMaxChars ??
|
|
16
|
-
user: config.memory?.userMaxChars ??
|
|
15
|
+
memory: config.memory?.memoryMaxChars ?? 10240,
|
|
16
|
+
user: config.memory?.userMaxChars ?? 10240,
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
function fileOf(target) {
|
|
@@ -42,14 +42,15 @@ export function createMemory(paths, config, logger) {
|
|
|
42
42
|
const text = entries.join(SEP);
|
|
43
43
|
const used = text.length;
|
|
44
44
|
const pct = Math.round((used / limits[target]) * 100);
|
|
45
|
-
|
|
45
|
+
const fileName = path.basename(fileOf(target));
|
|
46
|
+
return `${LINE}\n${fileName}(${label}) [${pct}% — ${used}/${limits[target]} chars]\n${LINE}\n${text}`;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/** 渲染注入系统 prompt 的记忆快照(空记忆返回空串)。 */
|
|
49
50
|
function snapshot() {
|
|
50
51
|
const blocks = [
|
|
51
|
-
renderBlock("
|
|
52
|
-
renderBlock("
|
|
52
|
+
renderBlock("环境与经验", "memory"),
|
|
53
|
+
renderBlock("用户偏好", "user"),
|
|
53
54
|
].filter(Boolean);
|
|
54
55
|
return blocks.join("\n\n");
|
|
55
56
|
}
|
package/base/test/core.test.js
CHANGED
|
@@ -27,7 +27,13 @@ test("系统提示词在 CLI 渠道下提示终端格式", () => {
|
|
|
27
27
|
assert.match(sys, /运行环境/);
|
|
28
28
|
assert.match(sys, /Node\.js/);
|
|
29
29
|
assert.match(sys, /操作系统/);
|
|
30
|
+
assert.match(sys, /时区/);
|
|
30
31
|
assert.match(sys, /UTC[+-]\d{2}:\d{2}/);
|
|
32
|
+
assert.doesNotMatch(sys, /当前时间/);
|
|
33
|
+
assert.match(sys, /MEMORY\.md/);
|
|
34
|
+
assert.match(sys, /USER\.md/);
|
|
35
|
+
assert.match(sys, /10KB/);
|
|
36
|
+
assert.match(sys, /MEMORY-xxx\.md/);
|
|
31
37
|
});
|
|
32
38
|
|
|
33
39
|
test("系统提示词注入 Vanor 实例上下文", () => {
|
|
@@ -77,7 +83,7 @@ test("系统提示词在 Intl 缺失时仍可构造时区信息", () => {
|
|
|
77
83
|
channel: "cli",
|
|
78
84
|
i18n: createI18n("zh-CN"),
|
|
79
85
|
});
|
|
80
|
-
assert.match(sys,
|
|
86
|
+
assert.match(sys, /时区/);
|
|
81
87
|
assert.match(sys, /UTC[+-]\d{2}:\d{2}/);
|
|
82
88
|
} finally {
|
|
83
89
|
if (desc) Object.defineProperty(globalThis, "Intl", desc);
|
|
@@ -149,6 +155,10 @@ test("系统提示词支持英文语言", () => {
|
|
|
149
155
|
});
|
|
150
156
|
assert.match(sys, /Runtime environment/);
|
|
151
157
|
assert.match(sys, /Default response language: English/);
|
|
158
|
+
assert.match(sys, /Time zone/);
|
|
159
|
+
assert.doesNotMatch(sys, /Current time/);
|
|
160
|
+
assert.match(sys, /Long-term memory policy/);
|
|
161
|
+
assert.match(sys, /USER-xxx\.md/);
|
|
152
162
|
assert.match(sys, /Terminal capabilities/);
|
|
153
163
|
assert.doesNotMatch(sys, /运行环境/);
|
|
154
164
|
});
|
package/base/test/loop.test.js
CHANGED
|
@@ -6,7 +6,7 @@ import fs from "node:fs";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { createSecurity } from "../security/index.js";
|
|
9
|
-
import { defaultRegistry, createToolContext } from "../tools/index.js";
|
|
9
|
+
import { defaultRegistry, createToolContext, ToolRegistry } from "../tools/index.js";
|
|
10
10
|
import { Session } from "../core/session.js";
|
|
11
11
|
import { runLoop } from "../core/loop.js";
|
|
12
12
|
import { NullLogger } from "../logger.js";
|
|
@@ -95,3 +95,92 @@ test("达到最大迭代次数会停止", async () => {
|
|
|
95
95
|
const result = await runLoop(session, deps, {});
|
|
96
96
|
assert.equal(result.maxIterations, true);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test("工具执行阶段中断后 loop 返回 aborted 且不继续下一轮", async () => {
|
|
100
|
+
const ws = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-loop-abort-"));
|
|
101
|
+
const ac = new AbortController();
|
|
102
|
+
const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
|
|
103
|
+
const registry = new ToolRegistry().register({
|
|
104
|
+
name: "abort_tool",
|
|
105
|
+
description: "abort",
|
|
106
|
+
danger: "safe",
|
|
107
|
+
parameters: { type: "object", properties: {} },
|
|
108
|
+
handler() {
|
|
109
|
+
ac.abort();
|
|
110
|
+
return { content: "aborted" };
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const toolCtx = createToolContext({ security, logger: new NullLogger(), signal: ac.signal, confirm: async () => true });
|
|
114
|
+
const session = new Session({ sessions: path.join(ws, "s") }, new NullLogger(), {});
|
|
115
|
+
session.start();
|
|
116
|
+
session.addMessage({ id: "u1", role: "user", content: "abort", meta: {} });
|
|
117
|
+
let calls = 0;
|
|
118
|
+
const llm = {
|
|
119
|
+
async *stream() {
|
|
120
|
+
calls++;
|
|
121
|
+
yield { type: "tool_call", call: { id: "c", name: "abort_tool", arguments: {} } };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const deps = {
|
|
125
|
+
llm,
|
|
126
|
+
registry,
|
|
127
|
+
toolCtx,
|
|
128
|
+
config: { agent: { maxIterations: 5 }, memory: {} },
|
|
129
|
+
model: "mock/m",
|
|
130
|
+
tools: registry.schemas(),
|
|
131
|
+
buildSystem: () => "system",
|
|
132
|
+
logger: new NullLogger(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const result = await runLoop(session, deps, {}, ac.signal);
|
|
136
|
+
assert.equal(result.aborted, true);
|
|
137
|
+
assert.equal(calls, 1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("工具切换模型后 loop 后续迭代使用最新模型", async () => {
|
|
141
|
+
const ws = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-loop-model-"));
|
|
142
|
+
const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
|
|
143
|
+
let currentModel = "mock/old";
|
|
144
|
+
const registry = new ToolRegistry().register({
|
|
145
|
+
name: "switch_model",
|
|
146
|
+
description: "switch model",
|
|
147
|
+
danger: "safe",
|
|
148
|
+
parameters: { type: "object", properties: {} },
|
|
149
|
+
handler() {
|
|
150
|
+
currentModel = "mock/new";
|
|
151
|
+
return { content: "switched" };
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const toolCtx = createToolContext({ security, logger: new NullLogger(), confirm: async () => true });
|
|
155
|
+
const session = new Session({ sessions: path.join(ws, "s") }, new NullLogger(), {});
|
|
156
|
+
session.start();
|
|
157
|
+
session.addMessage({ id: "u1", role: "user", content: "switch", meta: {} });
|
|
158
|
+
const requestedModels = [];
|
|
159
|
+
let calls = 0;
|
|
160
|
+
const llm = {
|
|
161
|
+
async *stream(req) {
|
|
162
|
+
requestedModels.push(req.model);
|
|
163
|
+
calls++;
|
|
164
|
+
if (calls === 1) {
|
|
165
|
+
yield { type: "tool_call", call: { id: "c", name: "switch_model", arguments: {} } };
|
|
166
|
+
} else {
|
|
167
|
+
yield { type: "text", delta: "ok" };
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const deps = {
|
|
172
|
+
llm,
|
|
173
|
+
registry,
|
|
174
|
+
toolCtx,
|
|
175
|
+
config: { agent: { maxIterations: 5 }, memory: {} },
|
|
176
|
+
model: currentModel,
|
|
177
|
+
getModel: () => currentModel,
|
|
178
|
+
tools: registry.schemas(),
|
|
179
|
+
buildSystem: () => "system",
|
|
180
|
+
logger: new NullLogger(),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const result = await runLoop(session, deps, {});
|
|
184
|
+
assert.equal(result.assistant.content, "ok");
|
|
185
|
+
assert.deepEqual(requestedModels, ["mock/old", "mock/new"]);
|
|
186
|
+
});
|
package/base/test/memory.test.js
CHANGED
|
@@ -11,7 +11,7 @@ function tempPaths() {
|
|
|
11
11
|
return { memory: path.join(root, "memory"), working: path.join(root, "memory", "working") };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const config = { memory: { memoryMaxChars:
|
|
14
|
+
const config = { memory: { memoryMaxChars: 10240, userMaxChars: 10240 } };
|
|
15
15
|
|
|
16
16
|
test("add 后 snapshot 含条目", () => {
|
|
17
17
|
const mem = createMemory(tempPaths(), config, new NullLogger());
|
|
@@ -19,6 +19,15 @@ test("add 后 snapshot 含条目", () => {
|
|
|
19
19
|
assert.match(mem.snapshot(), /macOS/);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
test("snapshot 标题包含长期记忆文件名", () => {
|
|
23
|
+
const mem = createMemory(tempPaths(), config, new NullLogger());
|
|
24
|
+
mem.apply({ action: "add", target: "memory", content: "项目经验" });
|
|
25
|
+
mem.apply({ action: "add", target: "user", content: "用户偏好" });
|
|
26
|
+
const snapshot = mem.snapshot();
|
|
27
|
+
assert.match(snapshot, /MEMORY\.md(环境与经验)/);
|
|
28
|
+
assert.match(snapshot, /USER\.md(用户偏好)/);
|
|
29
|
+
});
|
|
30
|
+
|
|
22
31
|
test("replace 唯一匹配", () => {
|
|
23
32
|
const mem = createMemory(tempPaths(), config, new NullLogger());
|
|
24
33
|
mem.apply({ action: "add", target: "user", content: "偏好深色模式" });
|
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { readSessionState, saveLatestSessionId, Session, latestSession, listSessions, listUserMessages } from "../core/session.js";
|
|
7
7
|
import { createHarness } from "../core/harness.js";
|
|
8
|
+
import { createToolContext, runToolCall } from "../tools/index.js";
|
|
8
9
|
import { NullLogger } from "../logger.js";
|
|
9
10
|
|
|
10
11
|
function tempPaths() {
|
|
@@ -256,6 +257,49 @@ test("createHarness 支持切换语言并写回配置", () => {
|
|
|
256
257
|
assert.equal(raw.llm.providers.p.apiKey, "env:VANOR_TEST_KEY");
|
|
257
258
|
});
|
|
258
259
|
|
|
260
|
+
test("createHarness 注册 vanor 工具并支持通过 slash 命令切换模型", async () => {
|
|
261
|
+
const paths = tempPaths();
|
|
262
|
+
const config = {
|
|
263
|
+
llm: {
|
|
264
|
+
defaultModel: "p/deepseek/deepseek-v4-pro",
|
|
265
|
+
providers: {
|
|
266
|
+
p: {
|
|
267
|
+
type: "openai",
|
|
268
|
+
baseURL: "http://example",
|
|
269
|
+
apiKey: "k",
|
|
270
|
+
models: ["deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash"],
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
logging: {},
|
|
275
|
+
memory: {},
|
|
276
|
+
agent: {},
|
|
277
|
+
security: { approval: "ask", workspaceRoot: paths.root },
|
|
278
|
+
};
|
|
279
|
+
const harness = createHarness({ paths, logger: new NullLogger(), config });
|
|
280
|
+
assert.ok(harness.registry.schemas().some((tool) => tool.name === "vanor"));
|
|
281
|
+
|
|
282
|
+
const ctx = createToolContext({ security: harness.security, logger: new NullLogger(), i18n: harness.i18n, confirm: async () => true });
|
|
283
|
+
const switched = await runToolCall({ id: "v1", name: "vanor", arguments: { command: "/model flash" } }, harness.registry, ctx);
|
|
284
|
+
assert.ok(!switched.meta.isError);
|
|
285
|
+
assert.equal(harness.model, "p/deepseek/deepseek-v4-flash");
|
|
286
|
+
|
|
287
|
+
let confirmCalls = 0;
|
|
288
|
+
const denyCtx = createToolContext({
|
|
289
|
+
security: harness.security,
|
|
290
|
+
logger: new NullLogger(),
|
|
291
|
+
i18n: harness.i18n,
|
|
292
|
+
confirm: async () => {
|
|
293
|
+
confirmCalls++;
|
|
294
|
+
return false;
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const denied = await runToolCall({ id: "v2", name: "vanor", arguments: { command: "/auto-run" } }, harness.registry, denyCtx);
|
|
298
|
+
assert.ok(denied.meta.isError);
|
|
299
|
+
assert.equal(confirmCalls, 1);
|
|
300
|
+
assert.equal(harness.security.approval, "ask");
|
|
301
|
+
});
|
|
302
|
+
|
|
259
303
|
test("热重载遇到无效配置时保留旧配置", () => {
|
|
260
304
|
const paths = tempPaths();
|
|
261
305
|
paths.config = path.join(paths.root, "config.json");
|
package/base/test/tools.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createSecurity } from "../security/index.js";
|
|
7
|
-
import { defaultRegistry, createToolContext, runToolCall } from "../tools/index.js";
|
|
7
|
+
import { defaultRegistry, createToolContext, runToolCall, runToolCalls, ToolRegistry } from "../tools/index.js";
|
|
8
8
|
import { NullLogger } from "../logger.js";
|
|
9
9
|
|
|
10
10
|
function tempWorkspace() {
|
|
@@ -125,3 +125,63 @@ test("denylist 命中 exec 被拒", async () => {
|
|
|
125
125
|
);
|
|
126
126
|
assert.ok(r.meta.isError);
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
test("AbortSignal 会中断正在执行的 exec 命令及其子进程", async () => {
|
|
130
|
+
const ws = tempWorkspace();
|
|
131
|
+
const reg = defaultRegistry();
|
|
132
|
+
const ac = new AbortController();
|
|
133
|
+
const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
|
|
134
|
+
const ctx = createToolContext({ security, logger: new NullLogger(), signal: ac.signal, confirm: async () => true });
|
|
135
|
+
const script = "setTimeout(()=>require('fs').writeFileSync('done.txt','x'),500)";
|
|
136
|
+
const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`;
|
|
137
|
+
|
|
138
|
+
const pending = runToolCall({ id: "1", name: "exec", arguments: { command, timeout: 5000 } }, reg, ctx);
|
|
139
|
+
setTimeout(() => ac.abort(), 50);
|
|
140
|
+
const r = await pending;
|
|
141
|
+
|
|
142
|
+
assert.ok(r.meta.isError);
|
|
143
|
+
assert.match(r.content, /aborted|中断/i);
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
145
|
+
assert.ok(!fs.existsSync(path.join(ws, "done.txt")));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("AbortSignal 触发后不继续执行后续工具", async () => {
|
|
149
|
+
const ws = tempWorkspace();
|
|
150
|
+
const ac = new AbortController();
|
|
151
|
+
const security = createSecurity({ security: { approval: "auto", workspaceRoot: ws } });
|
|
152
|
+
const ctx = createToolContext({ security, logger: new NullLogger(), signal: ac.signal, confirm: async () => true });
|
|
153
|
+
let secondRan = false;
|
|
154
|
+
const reg = new ToolRegistry()
|
|
155
|
+
.register({
|
|
156
|
+
name: "first",
|
|
157
|
+
description: "first",
|
|
158
|
+
danger: "safe",
|
|
159
|
+
parameters: { type: "object", properties: {} },
|
|
160
|
+
handler() {
|
|
161
|
+
ac.abort();
|
|
162
|
+
return { content: "aborted" };
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
.register({
|
|
166
|
+
name: "second",
|
|
167
|
+
description: "second",
|
|
168
|
+
danger: "safe",
|
|
169
|
+
parameters: { type: "object", properties: {} },
|
|
170
|
+
handler() {
|
|
171
|
+
secondRan = true;
|
|
172
|
+
return { content: "second" };
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const results = await runToolCalls(
|
|
177
|
+
[
|
|
178
|
+
{ id: "1", name: "first", arguments: {} },
|
|
179
|
+
{ id: "2", name: "second", arguments: {} },
|
|
180
|
+
],
|
|
181
|
+
reg,
|
|
182
|
+
ctx,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
assert.equal(results.length, 1);
|
|
186
|
+
assert.equal(secondRan, false);
|
|
187
|
+
});
|
package/base/test/tui.test.js
CHANGED
|
@@ -240,3 +240,72 @@ test("TerminalUI confirm 中 Ctrl-C 取消当前确认而不触发全局中断",
|
|
|
240
240
|
assert.equal(interrupted, false);
|
|
241
241
|
assert.equal(tui.buffer, "draft");
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
test("TerminalUI 支持 Bash 风格编辑快捷键", () => {
|
|
245
|
+
const input = new FakeInput();
|
|
246
|
+
const output = new FakeOutput();
|
|
247
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
248
|
+
tui.enable();
|
|
249
|
+
|
|
250
|
+
input.emit("data", "abcdef");
|
|
251
|
+
input.emit("data", "\x02"); // Ctrl-B
|
|
252
|
+
input.emit("data", "\x02");
|
|
253
|
+
input.emit("data", "\x02");
|
|
254
|
+
input.emit("data", "\x0b"); // Ctrl-K 删除光标后内容
|
|
255
|
+
assert.equal(tui.buffer, "abc");
|
|
256
|
+
|
|
257
|
+
input.emit("data", " def ghi");
|
|
258
|
+
input.emit("data", "\x17"); // Ctrl-W 删除光标前单词
|
|
259
|
+
assert.equal(tui.buffer, "abc def ");
|
|
260
|
+
|
|
261
|
+
input.emit("data", "\x01"); // Ctrl-A
|
|
262
|
+
input.emit("data", "\x04"); // Ctrl-D 删除光标处字符
|
|
263
|
+
assert.equal(tui.buffer, "bc def ");
|
|
264
|
+
|
|
265
|
+
input.emit("data", "\x05"); // Ctrl-E
|
|
266
|
+
input.emit("data", "\x06"); // Ctrl-F 位于末尾时保持不变
|
|
267
|
+
assert.equal(tui.cursor, Array.from(tui.buffer).length);
|
|
268
|
+
|
|
269
|
+
input.emit("data", "\x02");
|
|
270
|
+
input.emit("data", "\x15"); // Ctrl-U 删除光标前内容
|
|
271
|
+
assert.equal(tui.buffer, " ");
|
|
272
|
+
assert.equal(tui.cursor, 0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("TerminalUI 支持 Ctrl-P/Ctrl-N 导航历史", async () => {
|
|
276
|
+
const input = new FakeInput();
|
|
277
|
+
const output = new FakeOutput();
|
|
278
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
279
|
+
tui.enable();
|
|
280
|
+
|
|
281
|
+
input.emit("data", "first");
|
|
282
|
+
input.emit("data", "\r");
|
|
283
|
+
input.emit("data", "second");
|
|
284
|
+
input.emit("data", "\r");
|
|
285
|
+
assert.equal(await tui.readLine(), "first");
|
|
286
|
+
assert.equal(await tui.readLine(), "second");
|
|
287
|
+
|
|
288
|
+
input.emit("data", "\x10"); // Ctrl-P
|
|
289
|
+
assert.equal(tui.buffer, "second");
|
|
290
|
+
input.emit("data", "\x10");
|
|
291
|
+
assert.equal(tui.buffer, "first");
|
|
292
|
+
input.emit("data", "\x0e"); // Ctrl-N
|
|
293
|
+
assert.equal(tui.buffer, "second");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("TerminalUI Ctrl-L 清空消息区并保留输入草稿", () => {
|
|
297
|
+
const input = new FakeInput();
|
|
298
|
+
const output = new FakeOutput();
|
|
299
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
300
|
+
tui.enable();
|
|
301
|
+
tui.write("agent output\n");
|
|
302
|
+
input.emit("data", "draft");
|
|
303
|
+
|
|
304
|
+
input.emit("data", "\x0c"); // Ctrl-L
|
|
305
|
+
|
|
306
|
+
assert.equal(tui.buffer, "draft");
|
|
307
|
+
assert.equal(tui.cursor, 5);
|
|
308
|
+
assert.match(output.text(), /\x1b\[1;1H\x1b\[2K/);
|
|
309
|
+
assert.match(output.text(), /\x1b\[22;1H\x1b\[2K/);
|
|
310
|
+
assert.match(output.text(), /› draft/);
|
|
311
|
+
});
|
package/base/tools/builtin.js
CHANGED
|
@@ -150,6 +150,7 @@ const exec = {
|
|
|
150
150
|
required: ["command"],
|
|
151
151
|
},
|
|
152
152
|
handler(args, ctx) {
|
|
153
|
+
if (ctx.signal?.aborted) return fail(ctx.t("builtin.commandAborted"));
|
|
153
154
|
let cwd = ctx.security.workspaceRoot;
|
|
154
155
|
if (args.cwd) {
|
|
155
156
|
const r = ctx.security.checkPath(args.cwd, { mustExist: true, dir: true });
|
|
@@ -158,11 +159,42 @@ const exec = {
|
|
|
158
159
|
}
|
|
159
160
|
return new Promise((resolve) => {
|
|
160
161
|
let child;
|
|
162
|
+
let settled = false;
|
|
163
|
+
let aborted = false;
|
|
164
|
+
let abortKillTimer = null;
|
|
165
|
+
const detached = process.platform !== "win32";
|
|
166
|
+
const finish = (result) => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
if (abortKillTimer) clearTimeout(abortKillTimer);
|
|
170
|
+
ctx.signal?.removeEventListener?.("abort", onAbort);
|
|
171
|
+
resolve(result);
|
|
172
|
+
};
|
|
173
|
+
const killChild = (signalName) => {
|
|
174
|
+
if (!child?.pid) return;
|
|
175
|
+
try {
|
|
176
|
+
if (detached) process.kill(-child.pid, signalName);
|
|
177
|
+
else child.kill(signalName);
|
|
178
|
+
} catch {
|
|
179
|
+
try {
|
|
180
|
+
child.kill(signalName);
|
|
181
|
+
} catch {
|
|
182
|
+
// Process may have already exited.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const onAbort = () => {
|
|
187
|
+
aborted = true;
|
|
188
|
+
killChild("SIGTERM");
|
|
189
|
+
abortKillTimer = setTimeout(() => killChild("SIGKILL"), 1000);
|
|
190
|
+
};
|
|
161
191
|
try {
|
|
162
|
-
child = spawn(args.command, { shell: true, cwd, signal: ctx.signal });
|
|
192
|
+
child = spawn(args.command, { shell: true, cwd, signal: ctx.signal, detached });
|
|
163
193
|
} catch (e) {
|
|
164
|
-
return
|
|
194
|
+
return finish(fail(ctx.t("builtin.spawnFailed", { message: e.message })));
|
|
165
195
|
}
|
|
196
|
+
if (ctx.signal?.aborted) onAbort();
|
|
197
|
+
else ctx.signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
166
198
|
let out = "";
|
|
167
199
|
let truncated = false;
|
|
168
200
|
const onData = (d) => {
|
|
@@ -171,16 +203,17 @@ const exec = {
|
|
|
171
203
|
};
|
|
172
204
|
child.stdout?.on("data", onData);
|
|
173
205
|
child.stderr?.on("data", onData);
|
|
174
|
-
const timer = setTimeout(() =>
|
|
206
|
+
const timer = setTimeout(() => killChild("SIGKILL"), args.timeout || 60000);
|
|
175
207
|
child.on("error", (e) => {
|
|
176
208
|
clearTimeout(timer);
|
|
177
|
-
if (e.name === "AbortError")
|
|
178
|
-
else
|
|
209
|
+
if (e.name === "AbortError" || aborted) finish(fail(ctx.t("builtin.commandAborted")));
|
|
210
|
+
else finish(fail(ctx.t("builtin.execFailed", { message: e.message })));
|
|
179
211
|
});
|
|
180
212
|
child.on("close", (code) => {
|
|
181
213
|
clearTimeout(timer);
|
|
214
|
+
if (aborted) return finish(fail(ctx.t("builtin.commandAborted")));
|
|
182
215
|
const note = truncated ? `\n${ctx.t("builtin.outputTruncated")}` : "";
|
|
183
|
-
|
|
216
|
+
finish({
|
|
184
217
|
content: `${out || ctx.t("builtin.noOutput")}${note}\n${ctx.t("builtin.exitCode", { code })}`,
|
|
185
218
|
isError: code !== 0,
|
|
186
219
|
});
|
package/base/tools/index.js
CHANGED
|
@@ -70,6 +70,8 @@ function describe(call, t) {
|
|
|
70
70
|
return t("tools.describeEditFile", { path: a.path });
|
|
71
71
|
case "skills":
|
|
72
72
|
return t("tools.describeSkills", { action: a.action, name: a.name ? ` ${a.name}` : "" });
|
|
73
|
+
case "vanor":
|
|
74
|
+
return t("tools.describeVanor", { command: a.command });
|
|
73
75
|
default:
|
|
74
76
|
return t("tools.describeGeneric", { name: call.name });
|
|
75
77
|
}
|
|
@@ -100,6 +102,15 @@ export async function runToolCall(call, registry, ctx) {
|
|
|
100
102
|
});
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
if (ctx.signal?.aborted) {
|
|
106
|
+
return toolMessage({
|
|
107
|
+
toolCallId: call.id,
|
|
108
|
+
name: call.name,
|
|
109
|
+
content: ctx.t("tools.aborted"),
|
|
110
|
+
isError: true,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
103
114
|
const errs = validateSchema(tool.parameters, call.arguments || {}, "", ctx.t);
|
|
104
115
|
if (errs.length) {
|
|
105
116
|
return toolMessage({
|
|
@@ -149,9 +160,11 @@ export async function runToolCall(call, registry, ctx) {
|
|
|
149
160
|
export async function runToolCalls(toolCalls, registry, ctx, onResult) {
|
|
150
161
|
const out = [];
|
|
151
162
|
for (const call of toolCalls) {
|
|
163
|
+
if (ctx.signal?.aborted) break;
|
|
152
164
|
const msg = await runToolCall(call, registry, ctx);
|
|
153
165
|
if (onResult) onResult(call, msg);
|
|
154
166
|
out.push(msg);
|
|
167
|
+
if (ctx.signal?.aborted) break;
|
|
155
168
|
}
|
|
156
169
|
return out;
|
|
157
170
|
}
|
package/base/transport/cli.js
CHANGED
|
@@ -373,7 +373,7 @@ export async function startCli(harness, { config }) {
|
|
|
373
373
|
break; // rl 关闭
|
|
374
374
|
}
|
|
375
375
|
if (!input) continue;
|
|
376
|
-
tui?.echoInput(input);
|
|
376
|
+
tui?.echoInput(c.bold(c.cyan(input)));
|
|
377
377
|
|
|
378
378
|
if (input.startsWith("/")) {
|
|
379
379
|
const consumed = await handleSlash(input);
|
package/base/transport/tui.js
CHANGED
|
@@ -173,6 +173,19 @@ export class TerminalUI {
|
|
|
173
173
|
this.write(`${this.prompt}${line}\n`);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
clearOutput() {
|
|
177
|
+
if (!this.enabled) return;
|
|
178
|
+
const bottom = this._scrollBottom();
|
|
179
|
+
let seq = "\x1b7";
|
|
180
|
+
for (let row = 1; row <= bottom; row++) seq += `\x1b[${row};1H\x1b[2K`;
|
|
181
|
+
seq += "\x1b8";
|
|
182
|
+
this.output.write(seq);
|
|
183
|
+
this.outRow = 1;
|
|
184
|
+
this.outCol = 1;
|
|
185
|
+
this.statusBar?.render?.(true);
|
|
186
|
+
this.renderInput(true);
|
|
187
|
+
}
|
|
188
|
+
|
|
176
189
|
readLine() {
|
|
177
190
|
const queued = this.queue.shift();
|
|
178
191
|
if (queued !== undefined) return Promise.resolve(queued);
|
|
@@ -271,13 +284,22 @@ export class TerminalUI {
|
|
|
271
284
|
this._setBuffer(next, { fromHistory: true });
|
|
272
285
|
}
|
|
273
286
|
|
|
287
|
+
_resetHistoryDraft() {
|
|
288
|
+
this.historyIndex = null;
|
|
289
|
+
this._draftBeforeHistory = "";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_moveCursor(delta) {
|
|
293
|
+
this.cursor = Math.max(0, Math.min(chars(this.buffer).length, this.cursor + delta));
|
|
294
|
+
this.renderInput(true);
|
|
295
|
+
}
|
|
296
|
+
|
|
274
297
|
_insert(s, { multiline = false } = {}) {
|
|
275
298
|
const all = chars(this.buffer);
|
|
276
299
|
const text = multiline ? String(s).replace(/\r\n/g, "\n").replace(/\r/g, "\n") : String(s);
|
|
277
300
|
const ins = chars(text).filter((ch) => (multiline && ch === "\n") || printable(ch));
|
|
278
301
|
if (!ins.length) return;
|
|
279
|
-
this.
|
|
280
|
-
this._draftBeforeHistory = "";
|
|
302
|
+
this._resetHistoryDraft();
|
|
281
303
|
all.splice(this.cursor, 0, ...ins);
|
|
282
304
|
this.buffer = all.join("");
|
|
283
305
|
this.cursor += ins.length;
|
|
@@ -286,8 +308,7 @@ export class TerminalUI {
|
|
|
286
308
|
|
|
287
309
|
_backspace() {
|
|
288
310
|
if (this.cursor <= 0) return;
|
|
289
|
-
this.
|
|
290
|
-
this._draftBeforeHistory = "";
|
|
311
|
+
this._resetHistoryDraft();
|
|
291
312
|
const all = chars(this.buffer);
|
|
292
313
|
all.splice(this.cursor - 1, 1);
|
|
293
314
|
this.buffer = all.join("");
|
|
@@ -298,13 +319,44 @@ export class TerminalUI {
|
|
|
298
319
|
_delete() {
|
|
299
320
|
const all = chars(this.buffer);
|
|
300
321
|
if (this.cursor >= all.length) return;
|
|
301
|
-
this.
|
|
302
|
-
this._draftBeforeHistory = "";
|
|
322
|
+
this._resetHistoryDraft();
|
|
303
323
|
all.splice(this.cursor, 1);
|
|
304
324
|
this.buffer = all.join("");
|
|
305
325
|
this.renderInput(true);
|
|
306
326
|
}
|
|
307
327
|
|
|
328
|
+
_killBeforeCursor() {
|
|
329
|
+
if (this.cursor <= 0) return;
|
|
330
|
+
this._resetHistoryDraft();
|
|
331
|
+
const all = chars(this.buffer);
|
|
332
|
+
all.splice(0, this.cursor);
|
|
333
|
+
this.buffer = all.join("");
|
|
334
|
+
this.cursor = 0;
|
|
335
|
+
this.renderInput(true);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_killAfterCursor() {
|
|
339
|
+
const all = chars(this.buffer);
|
|
340
|
+
if (this.cursor >= all.length) return;
|
|
341
|
+
this._resetHistoryDraft();
|
|
342
|
+
all.splice(this.cursor);
|
|
343
|
+
this.buffer = all.join("");
|
|
344
|
+
this.renderInput(true);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
_killWordBeforeCursor() {
|
|
348
|
+
if (this.cursor <= 0) return;
|
|
349
|
+
this._resetHistoryDraft();
|
|
350
|
+
const all = chars(this.buffer);
|
|
351
|
+
let start = this.cursor;
|
|
352
|
+
while (start > 0 && /\s/.test(all[start - 1])) start--;
|
|
353
|
+
while (start > 0 && !/\s/.test(all[start - 1])) start--;
|
|
354
|
+
all.splice(start, this.cursor - start);
|
|
355
|
+
this.buffer = all.join("");
|
|
356
|
+
this.cursor = start;
|
|
357
|
+
this.renderInput(true);
|
|
358
|
+
}
|
|
359
|
+
|
|
308
360
|
_handleEscape(s) {
|
|
309
361
|
if (s === "\x1b[A") {
|
|
310
362
|
this._navigateHistory(-1);
|
|
@@ -315,12 +367,20 @@ export class TerminalUI {
|
|
|
315
367
|
return true;
|
|
316
368
|
}
|
|
317
369
|
if (s === "\x1b[D") {
|
|
318
|
-
this.
|
|
319
|
-
this.renderInput(true);
|
|
370
|
+
this._moveCursor(-1);
|
|
320
371
|
return true;
|
|
321
372
|
}
|
|
322
373
|
if (s === "\x1b[C") {
|
|
323
|
-
this.
|
|
374
|
+
this._moveCursor(1);
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
if (s === "\x1b[H" || s === "\x1b[1~") {
|
|
378
|
+
this.cursor = 0;
|
|
379
|
+
this.renderInput(true);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
if (s === "\x1b[F" || s === "\x1b[4~") {
|
|
383
|
+
this.cursor = chars(this.buffer).length;
|
|
324
384
|
this.renderInput(true);
|
|
325
385
|
return true;
|
|
326
386
|
}
|
|
@@ -383,15 +443,19 @@ export class TerminalUI {
|
|
|
383
443
|
this.cursor = 0;
|
|
384
444
|
return this.renderInput(true);
|
|
385
445
|
}
|
|
446
|
+
if (s === "\x02") return this._moveCursor(-1);
|
|
447
|
+
if (s === "\x04") return this._delete();
|
|
386
448
|
if (s === "\x05") {
|
|
387
449
|
this.cursor = chars(this.buffer).length;
|
|
388
450
|
return this.renderInput(true);
|
|
389
451
|
}
|
|
390
|
-
if (s === "\
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
452
|
+
if (s === "\x06") return this._moveCursor(1);
|
|
453
|
+
if (s === "\x0b") return this._killAfterCursor();
|
|
454
|
+
if (s === "\x0c") return this.clearOutput();
|
|
455
|
+
if (s === "\x0e") return this._navigateHistory(1);
|
|
456
|
+
if (s === "\x10") return this._navigateHistory(-1);
|
|
457
|
+
if (s === "\x15") return this._killBeforeCursor();
|
|
458
|
+
if (s === "\x17") return this._killWordBeforeCursor();
|
|
395
459
|
return this._insert(s);
|
|
396
460
|
}
|
|
397
461
|
}
|
package/docs/TECH_DESIGN.md
CHANGED
|
@@ -279,11 +279,14 @@ interface ToolResult { content: string; isError?: boolean; }
|
|
|
279
279
|
|
|
280
280
|
| 文件 | 用途 | 上限 |
|
|
281
281
|
|------|------|------|
|
|
282
|
-
| `MEMORY.md` |
|
|
283
|
-
| `USER.md` |
|
|
282
|
+
| `MEMORY.md` | 环境事实、约定、经验;过多时作为索引指向 `MEMORY-xxx.md` | 10KB |
|
|
283
|
+
| `USER.md` | 用户偏好、沟通风格;过多时作为索引指向 `USER-xxx.md` | 10KB |
|
|
284
284
|
|
|
285
285
|
- 条目以 `§` 分隔;超限时模型自行整合/替换。
|
|
286
286
|
- **冻结快照**:会话开始时注入系统 prompt,会话中途变更立即落盘但下一会话才进 prompt(保 prefix cache)。
|
|
287
|
+
- **每轮注入**:`MEMORY.md` 与 `USER.md` 是最高优先级长期记忆;只要存在内容,每次构造 system prompt 都会携带其当前快照。
|
|
288
|
+
- **任务结束更新判断**:system prompt 明确要求 Agent 在每次任务完成后判断是否需要写入稳定事实、项目约定、用户偏好或可复用经验。
|
|
289
|
+
- **二级索引**:两个主文件各限制 10KB;当信息过多时,主文件只保留索引摘要,详细内容写入 `MEMORY-xxx.md` / `USER-xxx.md`,需要时再二级读取。
|
|
287
290
|
- `memory` 工具:`add` / `replace` / `remove`(子串唯一匹配),无 `read`(已在 prompt 中)。
|
|
288
291
|
- **检索**:MVP 用关键词倒排(Node 实现)补充召回;`[v2]` 可选向量(sqlite-vec / 本地 embedding)。
|
|
289
292
|
|
|
@@ -329,7 +332,7 @@ type SessionEntry =
|
|
|
329
332
|
"fallback": ["wanyou/deepseek/deepseek-v4-flash"] // 主模型失败后依次尝试
|
|
330
333
|
},
|
|
331
334
|
"agent": { "maxIterations": 25, "thinking": "off", "contextWindow": 256000 },
|
|
332
|
-
"memory": { "memoryMaxChars":
|
|
335
|
+
"memory": { "memoryMaxChars": 10240, "userMaxChars": 10240, "compactThreshold": 0.75 },
|
|
333
336
|
"security": {
|
|
334
337
|
"approval": "ask", // ask | auto | deny
|
|
335
338
|
"allowlist": ["git *", "ls *", "cat *"],
|