Repository navigation
Expand file tree
/
Copy pathtools.ts
More file actions
142 lines (131 loc) · 5.6 KB
/
Copy pathtools.ts
File metadata and controls
142 lines (131 loc) · 5.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/**
* JevLoop · 工具
*
* 工具是唯一产生真实副作用的地方,所以这里有两道约束:
* 1. **路径锁死在工作目录内** —— 任何 `..` 逃逸都被拒绝
* 2. 每个工具声明一个**静态**风险基线,作为判定节点的参考
*
* 「这个调用到底危不危险」由 loop.gradeRisk 判定,不由工具自己决定 ——
* 静态基线只是给判定模型看的提示。
*
* @module JevLoop/tools
*/
import { readFile, writeFile, readdir, mkdir, stat } from 'node:fs/promises'
import { resolve, relative, dirname, sep } from 'node:path'
export interface Tool {
name: string
description: string
/** 静态风险基线 0..3。判定节点会参考它,但最终判定由模型做 */
baseRisk: number
run(input: string, cwd: string): Promise<string>
}
/** 把用户给的路径解析到 cwd 内,逃逸就抛 */
function safePath(cwd: string, p: string): string {
const full = resolve(cwd, p)
const rel = relative(cwd, full)
if (rel.startsWith('..') || (rel !== '' && rel.startsWith(sep))) {
throw new Error(`路径逃出工作目录:${p}`)
}
return full
}
/**
* `satisfies` 而不是 `: Record<string, Tool>` ——
* 后者会把键擦成 `string`,于是 `ToolName` 只能是 `string`,
* `defaultInput` 的 switch 就永远需要一个什么都接的 `default` 分支。
*/
export const TOOLS = {
list_dir: {
name: 'list_dir',
description: '列出工作目录里的文件(不含子目录内容)',
baseRisk: 0,
async run(_input: string, cwd: string): Promise<string> {
const entries = await readdir(cwd, { withFileTypes: true })
const out = entries
.filter((e) => !e.name.startsWith('.'))
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.sort()
// ★ 空目录返回**空串**,不是「(目录为空)」这种给人看的文案。
//
// 这个函数的返回值不只是给人读的:`agent.ts` 会把它
// `split('\n').filter(l => l && !l.endsWith('/'))` 当成**文件列表**解析
// (写进 `ctx.files`,再进 `pickInput` 的候选)。
// 返回文案时那句文案本身就成了"文件名":实测空目录下 `ctx.files = ['(目录为空)']`、
// `hasFileOptions` 为 true、`read_file` 去读它得到 ENOENT;
// 更糟的是**写路径** —— `write_file` 会在用户目录里创建一个真名叫
// `(目录为空)` 的文件并报告「已写入」。
//
// 代价:界面上看不到「目录为空」这句话了。那是**显示层**该说的话,
// 等 `Tool.run()` 有了结构化返回(见 REVIEWS-round6 的移交)再挪过去 ——
// 在返回值还是散文的情况下,任何非空文案都会被解析成一个文件。
return out.join('\n')
},
},
read_file: {
name: 'read_file',
description: '读取一个文件的内容。输入是相对工作目录的路径',
baseRisk: 0,
async run(input: string, cwd: string): Promise<string> {
const path = safePath(cwd, input.trim())
const info = await stat(path)
if (info.isDirectory()) return `(这是一个目录,请用 list_dir)`
const text = await readFile(path, 'utf8')
return text.length > 4000 ? text.slice(0, 4000) + `\n…[+${text.length - 4000} chars]` : text
},
},
write_file: {
name: 'write_file',
description: '写入或覆盖一个文件。输入格式:`路径\n内容`(第一行是路径,其余是内容)',
baseRisk: 1,
async run(input: string, cwd: string): Promise<string> {
const nl = input.indexOf('\n')
if (nl < 0) throw new Error('write_file 需要两行输入:第一行路径,其余内容')
const path = safePath(cwd, input.slice(0, nl).trim())
const content = input.slice(nl + 1)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, content, 'utf8')
return `已写入 ${relative(cwd, path)}(${content.length} 字符)`
},
},
done: {
name: 'done',
description: '任务已完成,不需要再调用任何工具',
baseRisk: 0,
async run(): Promise<string> {
return '任务标记为完成'
},
},
} satisfies Record<string, Tool>
/** 工具名的**封闭**联合。来自 TOOLS 的实际键,不是 `string` */
export type ToolName = keyof typeof TOOLS
/**
* 模型返回的工具名是不可信输入 —— 调用前必须过这一道。
*
* 不过会怎样:`defaultInput` 静默返回 `''`,`callTool` 返回
* 「错误:没有这个工具」,**而这个错误被当成普通工具输出喂给了 `stepOk` 判定**。
* 判定模型看到的是一段文本,它无法区分「工具跑出来的结果」和「工具根本不存在」。
*/
export function isToolName(v: string): v is ToolName {
return Object.hasOwn(TOOLS, v)
}
/**
* 全部工具名,顺序即 `TOOLS` 的声明顺序。
*
* 用来展示或做基于名字的校验。**要判断一个字符串是不是工具名请用
* `isToolName()`** —— 它走 `Object.hasOwn`,不会被 `__proto__`
* 这类继承来的键骗过去。
*/
export const toolNames = (): ToolName[] => Object.keys(TOOLS) as ToolName[]
/**
* 执行一次工具调用。
*
* 判定节点已经决定「放行 / 需要授权」了,这里只负责执行 ——
* 但**执行结果永远要回传**,因为判定「成功了吗」需要看到它。
*/
export async function callTool(name: ToolName, input: string, cwd: string): Promise<string> {
const tool = TOOLS[name]
try {
return await tool.run(input, cwd)
} catch (err) {
return `错误:${(err as Error).message}`
}
}