从零开发一个 Coding Agent(十三):实现安全的 read 文件读取工具
ä»é¶å¼åä¸ä¸ª Coding Agentï¼åä¸ï¼ï¼å®ç°å®å ¨ç read æä»¶è¯»åå·¥å ·
䏿¹å°æ 2026-08-20 0 é 读11åéCoding Agentç®åå·²ç»åå¸å°npmï¼å¤§å®¶å¯ä»¥å è¡ç´æ¥å ä½éªä¸ä¸æç»ä¼åæä»ä¹æ ·ãgithubå°åï¼github.com æ¬¢è¿ Star æ¯æï¼npmå°å@di-code/coding-agent - npmãç´æ¥
npm install -g @di-code/coding-agentå³å¯å®è£ 使ç¨
æ¬ç¯æç« æ¯ãä»é¶å¼åä¸ä¸ª Coding Agentãç³»å第åä¸ç¯ã
ä¸ä¸ç¯ä¸ï¼æä»¬å·²ç»å®æäº CLIãprint 模å¼å JSONL 模å¼ãç°å¨ç¨æ·å¯ä»¥ä»å½ä»¤è¡å Agent æé®ï¼Agent ä¹å¯ä»¥è°ç¨ Faux Provider å¾å°ç¡®å®æ§çåçã使¯æ¤æ¶ç Agent è¿åªè½âè天âï¼å®å¹¶ä¸è½çæ£æ¥ç项ç®ä¸çæä»¶ã
è¿ä¸ç¯æä»¬å°å®ç°ç¬¬ä¸ä¸ªæ¬å°å·¥å
·ï¼readã
宿åï¼è°ç¨æ¹å¯ä»¥è¿æ ·è¯»åæä»¶ï¼
const readTool = createReadTool("D:\\pi\\di-code");
const result = await readTool.execute("call-1", {
path: "packages/ai/src/index.ts",
offset: 1,
limit: 20,
});
å·¥å
·ä¼è¿åæä»¶ç¬¬ 1 å° 20 è¡ã妿æä»¶åé¢è¿æå
容ï¼ç»ææ«å°¾ä¼åè¯æ¨¡åä¸ä¸æ¬¡åºè¯¥ä½¿ç¨åªä¸ª offset ç»§ç»è¯»åã
ä¸è¿ï¼read ç»ä¸è½åªæ¯ç®åè°ç¨ä¸æ¬¡ readFile()ãä¸ä¸ª Coding Agent 读åçæ¯ç¨æ·çµèä¸çç宿件ï¼å æ¤å¿
é¡»åæ¶è§£å³è¿äºé®é¢ï¼
- Agent å¯ä»¥è¯»ååªäºç®å½ï¼
../è½ä¸è½è¶è¿é¡¹ç®æ ¹ç®å½ï¼- æ ¹ç®å½ä¸ç符å·é¾æ¥è½ä¸è½æåå¤é¨æä»¶ï¼
- 大æä»¶æ¯å¦ä¼ä¸æ¬¡æ§å¡æ»¡æ¨¡åä¸ä¸æï¼
- 䏿æå符æ°è¿æ¯æ UTF-8 åèæ°è®¡ç®ï¼
- äºè¿å¶æä»¶è½ä¸è½å½ä½ææ¬è¿åï¼
- æä½å·²ç»åæ¶æ¶ï¼å·¥å ·åºè¯¥ææ ·ç»æï¼
æä»¥è¿ä¸ç¯å®ç°ç䏿¯ä¸ä¸ªâè½è¯»æä»¶ç彿°âï¼èæ¯ä¸ä¸ªå¸¦ææç¡®æéè¾¹çåè¾åºè¾¹ççæä»¶è¯»åå·¥å ·ã
read å·¥å ·å¨é¡¹ç®ä¸çä½ç½®
å½å项ç®çä¾èµæ¹åæ¯ï¼
ai <- agent <- coding-agent
ä¸ä¸ªå çèè´£ä¸åï¼
| å | èè´£ |
|---|---|
@di-code/ai | å®ä¹æ¶æ¯ãå·¥å ·ç»æå Provider æ å ³çå ¬å ±åè®® |
@di-code/agent | è´è´£ Agent Loopãåæ°æ ¡éªãå·¥å ·è°ç¨åé误åé |
@di-code/coding-agent | è´è´£æä»¶ç³»ç»ãCLI ççå®äº§åè½å |
read ä¼è®¿é® Node.js æä»¶ç³»ç»ï¼æä»¥å®å¿
é¡»æ¾å¨ coding-agentï¼ä¸è½æ¾è¿éç¨ç agent å
ã
æ¬ç¯åªå建两个æä»¶ï¼
di-code/
packages/
coding-agent/
src/
core/
tools/
read.ts
test/
read-tool.test.ts
read.ts ä¿åå·¥å
·å®ç°ï¼read-tool.test.ts 使ç¨ä¸´æ¶ç®å½éªè¯æ£å¸¸è¯»åååç§å®å
¨è¾¹çã
è¿ä¸ç¯ææ¶ä¸æå·¥å
·æ¥å
¥ CLIï¼ä¹ä¸å®ç°â模å请æ±è¯»å -> å·¥å
·æ§è¡ -> ç»æåé -> 模ååçâç宿´æµç¨ãè¿éå
æåç¬ç read å·¥å
·åæ£ç¡®ï¼ç«¯å°ç«¯æ¥çº¿æ¾å°åç»æç« ã
䏿¬¡è¯»åç»è¿åªäºæ¥éª¤
å设å 许读åçæ ¹ç®å½æ¯ï¼
D:\pi\di-code
模å请æ±ï¼
{
"path": "packages/ai/src/index.ts",
"offset": 10,
"limit": 20
}
å·¥å ·ä¸ä¼ç«å»è¯»åæä»¶ï¼èæ¯ä¾æ¬¡æ§è¡ä¸é¢çæµç¨ï¼
flowchart TD
A[æ¶å° path offset limit] --> B[æ£æ¥åæ¶ååæ°]
B --> C[æè¾å
¥è·¯å¾è§£æä¸ºç»å¯¹è·¯å¾]
C --> D[æ£æ¥è¯æ³è·¯å¾æ²¡æè¶è¿æ ¹ç®å½]
D --> E[åå¾ç®æ çå®è·¯å¾ realpath]
E --> F[忬¡æ£æ¥çå®è·¯å¾æ²¡æè¶è¿æ ¹ç®å½]
F --> G[读å为 Buffer]
G --> H[æ£æ¥æ¯å¦åäºè¿å¶æä»¶]
H --> I[æ UTF-8 ææ¬åè¡]
I --> J[åºç¨ offset å limit]
J --> K[åºç¨æå¤§è¡æ°åæå¤§åèæ°]
K --> L[è¿åææ¬åç»§ç»è¯»åæç¤º]
è¿éæå®¹æå¿½ç¥çæ¯ä¸¤æ¬¡è·¯å¾æ£æ¥ï¼
- 读å忣æ¥ç¨æ·è¾å
¥è§£æåºæ¥çè·¯å¾ï¼é²æ¢
..ææ ¹å¤ç»å¯¹è·¯å¾ã realpath()å忣æ¥ä¸æ¬¡ï¼é²æ¢æ ¹ç®å½å ç符å·é¾æ¥æåå¤é¨æä»¶ã
åé¢ä¼ç¨å ·ä½ä¾åè§£é为ä»ä¹ä¸¤æ¬¡é½ä¸è½å°ã
å®ä¹ read å·¥å ·çåæ°
å建æä»¶ï¼
di-code/packages/coding-agent/src/core/tools/read.ts
å å å ¥ import åé»è®¤ä¸éï¼
import { readFile, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import type { AgentTool } from "@di-code/agent";
import { type Static, type ToolResultContent, Type } from "@di-code/ai";
export const DEFAULT_READ_MAX_LINES = 2_000;
export const DEFAULT_READ_MAX_BYTES = 50 * 1024;
readFile() è´è´£è¯»åæä»¶ï¼realpath() è´è´£åå¾æä»¶çæ£æåçä½ç½®ãnode:path ä¸ç彿°ç¨äºè§£æå¹¶å¤æè·¯å¾ã
é»è®¤æå¤è¿åï¼
- 2000 è¡ï¼
- 50 KiBï¼ä¹å°±æ¯
50 * 1024åèã
两个ä¸éåæ¶åå¨ãåªè¦å 碰å°å ¶ä¸ä»»ä½ä¸ä¸ªï¼å·¥å ·å°±åæ¢ç»§ç»å å ¥å 容ã
æ¥çå®ä¹æ¨¡åå¯ä»¥ä¼ å ¥çåæ°ï¼
export const readParameters = Type.Object({
path: Type.String({ minLength: 1 }),
offset: Type.Optional(Type.Integer({ minimum: 1 })),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
});
export type ReadParameters = Static<typeof readParameters>;
ä¸ä¸ªå段åå«è¡¨ç¤ºï¼
| åæ®µ | å«ä¹ | ç¤ºä¾ |
|---|---|---|
path | è¦è¯»åçæä»¶è·¯å¾ | "src/index.ts" |
offset | ä»ç¬¬å è¡å¼å§ï¼è¡å·ä» 1 å¼å§ | 10 |
limit | æå¤è¯»åå¤å°è¡ | 20 |
ä¾å¦ä¸ä¸ªæä»¶æ 100 è¡ï¼
{
"path": "notes.txt",
"offset": 10,
"limit": 3
}
表示读å第 10ã11ã12 è¡ãä¸ä¸æ¬¡ç»§ç»è¯»åæ¶ï¼åºè¯¥ä½¿ç¨ offset: 13ã
为ä»ä¹ offset ä» 1 å¼å§ï¼è䏿¯ä» 0 å¼å§ï¼å 为ç¼è¾å¨ãç»ç«¯é误信æ¯åäººç±»è®¨è®ºä»£ç æ¶é常é½è¯´â第 1 è¡âãå·¥å
·å¯¹å¤ä½¿ç¨ 1-based è¡å·ï¼å¯ä»¥åå°æ¨¡åæ¢ç®æ¶çé误ãåªæå¨è®¿é® JavaScript æ°ç»æ¶ï¼æè½¬æ¢æä» 0 å¼å§çç´¢å¼ã
åå®ä¹åå»ºå·¥å ·æ¶çé ç½®åå ¬å¼ç±»åï¼
export interface ReadToolOptions {
readonly maxLines?: number;
readonly maxBytes?: number;
}
export type ReadTool = AgentTool<typeof readParameters>;
offset å limit æ¯ä¸æ¬¡å·¥å
·è°ç¨çåæ°ï¼maxLines å maxBytes æ¯åºç¨å建工å
·æ¶è§å®çå®å
¨ä¸éãæ¨¡åå¯ä»¥è¯·æ±å°è¯»ä¸äºï¼ä½ä¸è½éè¿åæ°åæ¶åºç¨è®¾ç½®çæ»ä¸éã
为ä»ä¹ schema æ ¡éªåè¿è¦æ£æ¥åæ°
Agent Loop æ£å¸¸è°ç¨å·¥å
·æ¶ï¼ä¼æ ¹æ® readParameters æ ¡éªåæ°ã使µè¯ä»£ç ãæªæ¥ç SDK æå
¶ä»å
é¨ä»£ç ä¹å¯è½ç´æ¥è°ç¨ execute()ã
æä»¥å·¥å ·å é¨ä»ç¶è¦åé²å¾¡æ§æ£æ¥ï¼
function assertPositiveInteger(name: string, value: number | undefined): void {
if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
throw new Error(`${name} must be a positive integer`);
}
}
è¿ä¸ªå½æ°æ¥ååæ®µååæ°åï¼
- 没æä¼ 弿¶å 许éè¿ï¼å 为è¿äºåæ°æ¯å¯éçï¼
- ä¼ äºå¼ä½ä¸æ¯æ´æ°æ¶æç»ï¼
- å°äº 1 æ¶æç»ã
ä¾å¦ï¼
assertPositiveInteger("offset", 1); // éè¿
assertPositiveInteger("offset", 2.5); // æé
assertPositiveInteger("offset", 0); // æé
TypeScript ç±»ååªè½å¸®å©æä»¬æ£æ¥å¼åé¶æ®µç代ç ï¼æ æ³æ¿ä»£è¿è¡æ¶æ ¡éªã模åçæç JSONãCLI åæ°åç£çæ°æ®é½å±äºå¤é¨è¾å ¥ï¼ç¨åºå¨è¿è¡æ¶å¿ é¡»éæ°ç¡®è®¤å®ä»¬æ¯å¦ææã
æææ¬æ£ç¡®å°åæè¡
æ¥ä¸æ¥å®ä¹å·¥å ·å é¨ä½¿ç¨çææ¬çªå£ï¼
interface TextWindow {
readonly content: string;
readonly startLine: number;
readonly endLine: number;
readonly totalLines: number;
readonly truncatedBy: "limit" | "lines" | "bytes" | null;
}
å¯ä»¥æ TextWindow ç解为âè¿æ¬¡åå¤è¿åçæä»¶åçâãå®ä¸ä»
ä¿åææ¬ï¼è¿è¦è®°ä½ï¼
- ä»åªä¸è¡å¼å§ï¼
- å°åªä¸è¡ç»æï¼
- æä»¶æ»å ±æå¤å°è¡ï¼
- 为ä»ä¹åçæªæã
è¿äºä¿¡æ¯ä¼ç¨äºçæä¸ä¸æ¬¡è¯»åæç¤ºã
ç¶åå®ç°åè¡å½æ°ï¼
function splitLines(text: string): string[] {
if (text.length === 0) return [];
const lines = text.split(" ");
if (text.endsWith(" ")) lines.pop();
return lines;
}
为ä»ä¹ä¸è½åªå text.split(" ")ï¼çè¿ä¸ªæä»¶ï¼
line 1
line 2
ç´æ¥åå²ä¼å¾å°ï¼
["line 1", "line 2", ""]
æåç空åç¬¦ä¸²ä¸æ¯é¢å¤ç第 3 è¡å 容ï¼èæ¯æ«å°¾æ¢è¡ç¬¦äº§ççåéç»æï¼æä»¥éè¦å é¤ã
使件å é¨çæ£ç空è¡ä»ç¶åºè¯¥ä¿çãä¾å¦ï¼
line 1
line 3
åºè¯¥å¾å°ï¼
["line 1", "", "line 3"]
å¦å¤ï¼ç©ºæä»¶åºè¯¥æ¯ 0 è¡ï¼æä»¥ splitLines("") ç´æ¥è¿å空æ°ç»ã
å®ç° offset å limit
ç°å¨å®ç°ç¨æ·ä¸»å¨è¯·æ±çææ¬çªå£ï¼
function selectUserWindow(lines: readonly string[], offset: number, limit: number | undefined): TextWindow {
if (lines.length === 0) {
if (offset > 1) throw new Error(`Offset ${offset} is beyond end of file (0 lines total)`);
return { content: "", startLine: 1, endLine: 0, totalLines: 0, truncatedBy: null };
}
const startIndex = offset - 1;
if (startIndex >= lines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
}
const available = lines.slice(startIndex);
const selected = limit === undefined ? available : available.slice(0, limit);
const endLine = offset + selected.length - 1;
return {
content: selected.join(" "),
startLine: offset,
endLine,
totalLines: lines.length,
truncatedBy: limit !== undefined && selected.length < available.length ? "limit" : null,
};
}
ç¨ä¸ä¸ª 4 è¡æä»¶è§å¯è¿æ®µä»£ç ï¼
line 1
line 2
line 3
line 4
è°ç¨åæ°æ¯ï¼
{ offset: 2, limit: 2 }
计ç®è¿ç¨å¦ä¸ï¼
offset = 2
startIndex = offset - 1 = 1
available = ["line 2", "line 3", "line 4"]
selected = ["line 2", "line 3"]
endLine = 2 + 2 - 1 = 3
æç»è¿å第 2 å°ç¬¬ 3 è¡ï¼ä¸ä¸æ¬¡åºè¯¥ä»ç¬¬ 4 è¡ç»§ç»ã
妿 offset å·²ç»è¶
è¿æä»¶æ«å°¾ï¼å·¥å
·ä¸è½è¿åä¸ä¸ªçä¼¼æåç空å符串ãæç¡®æåºé误æ´å®¹æè®©æ¨¡ååç°èªå·±ä½¿ç¨äºé误çè¡å·ã
ç»æ¨¡åå å ¥ç»§ç»è¯»åæç¤º
å½ç¨æ·ç limit æªæå
容æ¶ï¼å¨ç»ææ«å°¾å å
¥æç¤ºï¼
function appendContinuation(window: TextWindow, maxBytes: number): string {
if (window.truncatedBy === null) return window.content;
const nextOffset = window.endLine + 1;
if (window.truncatedBy === "limit") {
const remaining = window.totalLines - window.endLine;
return `${window.content} [${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
}
const reason = window.truncatedBy === "bytes" ? ` (${formatByteLimit(maxBytes)} limit)` : "";
return `${window.content} [Showing lines ${window.startLine}-${window.endLine} of ${window.totalLines}${reason}. Use offset=${nextOffset} to continue.]`;
}
ä¾å¦è¯»å第 2ã3 è¡åï¼ç»ææ¯ï¼
line 2
line 3
[1 more lines in file. Use offset=4 to continue.]
è¿æ®µæç¤ºä¸»è¦æ¯ç»æ¨¡åççãæ¨¡åä¸éè¦éæ°çæµæä»¶è¿æå¤å°å 容ï¼ä¹ä¸éè¦èªå·±è®¡ç®ä¸ä¸æ¬¡çè¡å·ã
注æï¼å·¥å ·ç»æåªè¿åææ¬ï¼ä¸ç´æ¥åå ¥ stdoutãæç»æ¾ç¤ºä¸ºæ®éææ¬è¿æ¯ JSONLï¼åºè¯¥ç± CLI è¾åºå±å³å®ã
éå¶æä»¶åªè½å¨å è®¸æ ¹ç®å½å
æä»¶è¯»åå·¥å
·æéè¦çå®å
¨è§åæ¯ï¼ç®æ æä»¶å¿
é¡»ä½äº allowedRoot å
é¨ã
åè®¾æ ¹ç®å½æ¯ï¼
D:\pi\di-code
ä¸é¢çè·¯å¾åºè¯¥å 许ï¼
packages\ai\src\index.ts
D:\pi\di-code\package.json
ä¸é¢çè·¯å¾åºè¯¥æç»ï¼
..\secret.txt
D:\other-project\config.json
为ä»ä¹ä¸è½ç´æ¥ä½¿ç¨ startsWith
ä¸ç§çèµ·æ¥å¾ç®åçåæ³æ¯ï¼
target.startsWith(root)
ä½å®ä¸è½æ£ç¡®å¤æç®å½å ³ç³»ãä¾å¦ï¼
root = C:\work
target = C:\work-secret\password.txt
target çå符串确å®ä»¥ C:\work å¼å¤´ï¼ä½ work-secret 并䏿¯ work çåç®å½ã
æ£ç¡®æ¹å¼æ¯ä½¿ç¨ path.relative() 计ç®ä¸¤ä¸ªè·¯å¾ä¹é´çç®å½å
³ç³»ï¼
function assertInsideRoot(root: string, target: string): void {
const fromRoot = relative(root, target);
if (fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot))) {
return;
}
throw new Error("Path is outside the allowed root");
}
relative(root, target) çç»æå¯ä»¥è¿æ ·çè§£ï¼
| ç»æ | å«ä¹ |
|---|---|
"" | target å°±æ¯ root æ¬èº« |
"src\\index.ts" | target å¨ root å é¨ |
"..\\secret.txt" | target å¨ root å¤é¨ |
| ç»å¯¹è·¯å¾ | 两个路å¾ä¸å¨å¯ç´æ¥æ¯è¾çå䏿 ¹è·¯å¾ä¸ |
sep æ¯å½åæä½ç³»ç»çè·¯å¾åé符ãWindows éå¸¸æ¯ \ï¼Unix éå¸¸æ¯ /ãä½¿ç¨ Node çè·¯å¾ APIï¼ä¸è¦èªå·±æ¼æ¥æä¸ç§ç³»ç»çåé符ã
ç¬¬ä¸æ¬¡æ£æ¥ï¼é»æ¢ .. åæ ¹å¤ç»å¯¹è·¯å¾
å®ç°è·¯å¾è§£æï¼
async function resolveAllowedFile(inputPath: string, allowedRoot: string): Promise<string> {
const rootReal = await realpath(allowedRoot);
const candidate = resolve(rootReal, inputPath);
assertInsideRoot(rootReal, candidate);
const targetReal = await realpath(candidate);
assertInsideRoot(rootReal, targetReal);
return targetReal;
}
å çååæ®µï¼
const rootReal = await realpath(allowedRoot);
const candidate = resolve(rootReal, inputPath);
assertInsideRoot(rootReal, candidate);
resolve() 伿ç¸å¯¹è·¯å¾æ¾å°æ ¹ç®å½ä¸ï¼ä¹ä¼è§èå . å ..ã
ä¾å¦ï¼
root = D:\pi\di-code
inputPath = packages\ai\src\index.ts
candidate = D:\pi\di-code\packages\ai\src\index.ts
妿è¾å ¥æ¯ï¼
..\secret.txt
è§£æåä¼åæï¼
D:\pi\secret.txt
ç¬¬ä¸æ¬¡ assertInsideRoot() ä¼å¨è¯»åä¹åæç»å®ã
æ£æ¥å¿
é¡»æ¾å¨ç®æ æä»¶ç realpath() ä¹åãå¦åä¸ä¸ªæ ¹å¤ä¸ä¸åå¨çæä»¶å¯è½å
æåº ENOENTï¼ä»èæ´é²å¤é¨è·¯å¾æ¯å¦åå¨ï¼è¿ä¼è®©æéé误åæé¾ä»¥çè§£çâæä»¶ä¸åå¨âã
ç¬¬äºæ¬¡æ£æ¥ï¼é»æ¢ç¬¦å·é¾æ¥éé¸
åªæç¬¬ä¸æ¬¡æ£æ¥ä»ç¶ä¸å¤ãèèä¸é¢çç®å½ï¼
D:\pi\di-code\link.txt
-> D:\private\secret.txt
ä»å符串路å¾çï¼link.txt ä½äºæ ¹ç®å½å
ï¼æä»¥ç¬¬ä¸æ¬¡æ£æ¥ä¼éè¿ãä½å®çæ£æåçæä»¶ä½äºæ ¹ç®å½å¤ã
å æ¤è¿è¦æ§è¡ï¼
const targetReal = await realpath(candidate);
assertInsideRoot(rootReal, targetReal);
realpath() ä¼è§£æç¬¦å·é¾æ¥ï¼å¾å°ç®æ çæ£çä½ç½®ãç¬¬äºæ¬¡æ£æ¥ä¼åç° D:\private\secret.txt ä¸å¨å
è®¸æ ¹ç®å½å
ï¼å¹¶æåºï¼
Path is outside the allowed root
ä¸¤æ¬¡æ£æ¥è´è´£çé£é©ä¸åï¼
| æ£æ¥ | 黿¢ä»ä¹ |
|---|---|
candidate çè¯æ³è·¯å¾æ£æ¥ | ..ãæ ¹å¤ç»å¯¹è·¯å¾ï¼ä»¥å读ååçä¿¡æ¯æ³æ¼ |
targetReal ççå®è·¯å¾æ£æ¥ | æ ¹å 符å·é¾æ¥æåæ ¹å¤æä»¶ |
åªä¿çå ¶ä¸ä»»ä½ä¸æ¬¡é½ä¼ç䏿¼æ´ã
读å为 Bufferï¼è䏿¯ç´æ¥è¯»åå符串
è·¯å¾éè¿æ£æ¥åï¼ä½¿ç¨ä¸é¢ç代ç 读åæä»¶ï¼
const buffer = await readFile(absolutePath);
è¿é没æä¼ å
¥ "utf8"ï¼æä»¥è¿åçæ¯ Bufferï¼ä¹å°±æ¯åå§åèã
è¿æ ·åçåå æ¯ï¼å¨ææä»¶è§£éä¸ºææ¬ä¹åï¼æä»¬è¦å 夿宿¯å¦åäºè¿å¶æä»¶ï¼è¿è¦æ UTF-8 åèæ°æ§å¶è¾åºã
妿ä¸å¼å§å°±åï¼
await readFile(absolutePath, "utf8");
åå§åèä¼ç«å³è¢«è§£ç æ JavaScript å符串ï¼ä¹åååäºè¿å¶å¤æä¼æ´å°é¾ã
æç»ææ¾çäºè¿å¶æä»¶
Coding Agent çæ®éææ¬ä¸ä¸æä¸éåç´æ¥æ¥æ¶å¾çãå缩å
æå¯æ§è¡æä»¶ãä¸ä¸ªç®åå¹¶ä¸å¸¸è§çå¤ææ¹æ³æ¯æ£æ¥æä»¶å¼å¤´æ¯å¦åºç° NUL åèï¼ä¹å°±æ¯æ°å¼ 0ã
function containsNulByte(buffer: Buffer): boolean {
const sampleLength = Math.min(buffer.length, 8 * 1024);
for (let index = 0; index < sampleLength; index++) {
if (buffer[index] === 0) return true;
}
return false;
}
è¿éåªæ£æ¥å 8 KiBï¼è䏿¯æ«ææ´ä¸ªæä»¶ï¼
- 大夿°å¸¸è§äºè¿å¶æ ¼å¼å¾å¿«å°±ä¼åºç° NULï¼
- å·¥å ·å·²ç»ææä»¶è¯»å ¥å åï¼ä½æ²¡æå¿ è¦å宿´éå䏿¬¡å¤§æä»¶ï¼
- è¿æ¯âæ¯å¦åäºè¿å¶âçå®ç¨å¤æï¼ä¸æ¯å®æ´çæä»¶æ ¼å¼è¯å«å¨ã
ä½¿ç¨æ¶ï¼
if (containsNulByte(buffer)) {
throw new Error("Binary files are not supported by read");
}
æ¬ç¯åªæ¯æ UTF-8 ææ¬ãå¾ç读åãMIME æ£æµåä¸é¨çäºè¿å¶å·¥å ·ä¸å±äºå½åèå´ã
为ä»ä¹è¦æ UTF-8 åèæ°æªæ
JavaScript å符串ç .length 䏿¯ UTF-8 åèæ°ã
ä¾å¦ï¼
"a".length; // 1
Buffer.byteLength("a", "utf8"); // 1
"ä¸".length; // 1
Buffer.byteLength("ä¸", "utf8"); // 3
å¦æç¨ .length 计ç®è¾åºå¤§å°ï¼å¤§é䏿ä¼è¢«ä¸¥éä½ä¼°ãè¿éç»ä¸ä½¿ç¨ï¼
Buffer.byteLength(text, "utf8")
åªè¿å宿´è¡
å·¥å ·ä¸è½ä¸ºäºå好å¡å¨ 50 KiB èåæä¸è¡ä»£ç ãä¾å¦ä¸é¢çå 容ï¼
export function createSomethingImportant(
å¦æè¢«æªæï¼
export function createSome
模åå¯è½è¯¯ä»¥ä¸ºæä»¶ä¸ççåå¨ä¸ä¸ªä¸å®æ´çæ è¯ç¬¦ãå æ¤æä»¬çè§åæ¯ï¼å å ¥ä¸æ´è¡ä¹åå 计ç®å¤§å°ï¼è£ ä¸ä¸å°±åæ¢ï¼ä¸è¿ååè¡ã
å®ç°è¾åºä¸éï¼
function applyOutputLimits(window: TextWindow, maxLines: number, maxBytes: number): TextWindow {
if (window.content === "") return window;
const lines = window.content.split(" ");
const selected: string[] = [];
let bytes = 0;
let truncatedBy: "lines" | "bytes" | null = null;
for (const line of lines) {
if (selected.length >= maxLines) {
truncatedBy = "lines";
break;
}
const separatorBytes = selected.length === 0 ? 0 : 1;
const nextBytes = bytes + separatorBytes + Buffer.byteLength(line, "utf8");
if (nextBytes > maxBytes) {
if (selected.length === 0) {
throw new Error("A single line exceeds the read byte limit");
}
truncatedBy = "bytes";
break;
}
selected.push(line);
bytes = nextBytes;
}
if (truncatedBy === null && selected.length < lines.length) {
truncatedBy = "lines";
}
if (truncatedBy === null) return window;
return {
...window,
content: selected.join(" "),
endLine: window.startLine + selected.length - 1,
truncatedBy,
};
}
è¿éæå 个å¼å¾æ³¨æçç»èã
第ä¸ï¼ç¬¬äºè¡å¼å§ä¹åè¦è®¡ç®æ¢è¡ç¬¦ç 1 个åèï¼
const separatorBytes = selected.length === 0 ? 0 : 1;
第ä¸è¡å颿²¡ææ¢è¡ç¬¦ï¼åç»æ¯è¡ä¸ä¸ä¸è¡ä¹é´é½æä¸ä¸ª ã
第äºï¼endLine å¿
é¡»æç
§çæ£è¿åçè¡æ°éæ°è®¡ç®ãå¦æç¨æ·è¯·æ± 100 è¡ï¼ä½åèä¸éåªå®¹çº³ 2 è¡ï¼é£ä¹ä¸ä¸æ¬¡åºè¯¥ä»ç¬¬ 3 è¡ç»§ç»ï¼ä¸è½ä»ç¬¬ 101 è¡ç»§ç»ã
第ä¸ï¼å¦æç¬¬ä¸è¡æ¬èº«å°±è¶ è¿ä¸éï¼å·¥å ·éæ©æéï¼
A single line exceeds the read byte limit
妿è¿åç©ºææ¬å¹¶æç¤ºä»ä»ç¸å offset 读åï¼æ¨¡åä¸ä¸æ¬¡ä¼å次å¾å°åæ ·çç©ºç»æï¼å¯è½å½¢ææ ééè¯ã
æ ¼å¼ååèä¸éæç¤º
为äºè®©æç¤ºæ´å®¹æè¯»ï¼å å ¥ä¸ä¸ªå°å½æ°ï¼
function formatByteLimit(maxBytes: number): string {
if (maxBytes % 1024 === 0) return `${maxBytes / 1024} KiB`;
return `${maxBytes} bytes`;
}
ä¾å¦ï¼
51200 -> 50 KiB
125 -> 125 bytes
å½å 容å åèä¸éæªææ¶ï¼ç»æå¯è½æ¯ï¼
line 1
line 2
[Showing lines 1-2 of 100 (50 KiB limit). Use offset=3 to continue.]
è¿æ ·æ¨¡åç¥éæªæä¸æ¯æä»¶ç»æï¼èæ¯å·¥å ·çè¾åºé¢ç®å·²ç»ç¨å®ã
å¤çåæ¶
å·¥å
·ç execute() å¯ä»¥æ¶å°ä¸ä¸ª AbortSignalãå½è°ç¨æ¹å·²ç»åæ¶å½åè¯·æ±æ¶ï¼read å·¥å
·ä¸åºè¯¥ç»§ç»å¼å§æ°çæä»¶æä½ã
æ¬ç¯åºå®æ£æ¥ä¸¤ä¸ªæ¶é´ç¹ï¼
if (signal?.aborted) throw new Error("Operation aborted");
const absolutePath = await resolveAllowedFile(parameters.path, allowedRoot);
if (signal?.aborted) throw new Error("Operation aborted");
ç¬¬ä¸æ¬¡æ£æ¥åç卿æå·¥ä½ä¹åï¼ç¬¬äºæ¬¡æ£æ¥åçå¨å¼æ¥è·¯å¾è§£æä¹åãçæ£è¯»åæä»¶ä¹åã
è¿è½å¤çï¼
- è°ç¨å·¥å ·ä¹å就已ç»åæ¶ï¼
- çå¾
realpath()æé´åçåæ¶ã
å½åçæ¬æ²¡ææ¿è¯ºå¨ä»»ææ¶å»å¼ºå¶ä¸æå·²ç»å¼å§çç£ç读åãåæ¶è¯ä¹åºè¯¥ä¸æ´ä¸ªå·¥å ·ç³»ç»ç»ä¸è®¾è®¡ï¼ä¸è½å¨ä¸ä¸ªå·¥å ·éèæâ忥代ç éæ¶å¯åæ¢âçè½åã
ç»è£ createReadTool
ç°å¨æåé¢ç helper è¿æ¥æçæ£çå·¥å ·å·¥åï¼
export function createReadTool(allowedRoot: string, options: ReadToolOptions = {}): ReadTool {
const maxLines = options.maxLines ?? DEFAULT_READ_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_READ_MAX_BYTES;
assertPositiveInteger("maxLines", maxLines);
assertPositiveInteger("maxBytes", maxBytes);
return {
name: "read",
description: "Read a UTF-8 text file inside the allowed root. Use offset and limit for large files.",
parameters: readParameters,
async execute(_toolCallId, parameters, signal): Promise<ToolResultContent[]> {
if (signal?.aborted) throw new Error("Operation aborted");
if (parameters.path.length === 0) throw new Error("path must not be empty");
assertPositiveInteger("offset", parameters.offset);
assertPositiveInteger("limit", parameters.limit);
const absolutePath = await resolveAllowedFile(parameters.path, allowedRoot);
if (signal?.aborted) throw new Error("Operation aborted");
const buffer = await readFile(absolutePath);
if (containsNulByte(buffer)) {
throw new Error("Binary files are not supported by read");
}
const userWindow = selectUserWindow(
splitLines(buffer.toString("utf8")),
parameters.offset ?? 1,
parameters.limit,
);
const boundedWindow = applyOutputLimits(userWindow, maxLines, maxBytes);
return [{ type: "text", text: appendContinuation(boundedWindow, maxBytes) }];
},
};
}
宿´æ§å¶æµç¨å¯ä»¥æ¦æ¬ä¸ºï¼
å建工å
·
-> ç¡®å® maxLines/maxBytes
-> æ ¡éªå·¥å
·é
ç½®
æ¯æ¬¡ execute
-> æ£æ¥åæ¶
-> æ ¡éª path/offset/limit
-> æ£æ¥è¯æ³è·¯å¾
-> æ£æ¥çå®è·¯å¾
-> 读å Buffer
-> æç»äºè¿å¶
-> 转æ UTF-8 å¹¶åè¡
-> åºç¨ offset/limit
-> åºç¨è¡æ°/åèä¸é
-> è¿å ToolResultContent[]
execute() è¿åçæ¯ ToolResultContent[]ï¼
[{ type: "text", text: "æä»¶å
容" }]
ä¸è¦åå
ä¸å± { content: [...] }ï¼å 为å½å项ç®ä¸ç AgentTool.execute() å¥çº¦å°±æ¯ç´æ¥è¿åå
容忰ç»ã
å·¥å
·éå°é误æ¶ä¹ä¸éè¦èªå·±å建 isError: true çæ¶æ¯ãå®åªè¦æåºå¼å¸¸ï¼Agent Loop ä¼è´è´£æå¼å¸¸è½¬æ¢æé误工å
·ç»æãè¿æ ·æä»¶å·¥å
·ä¸éè¦äºè§£æ¶æ¯åå²åå¾ªç¯æ§å¶ã
为 read å·¥å ·ç¼åæµè¯
å建æä»¶ï¼
di-code/packages/coding-agent/test/read-tool.test.ts
æµè¯ä¸è¦è¯»åä»åºä¸çç宿件ï¼èè¦ä¸ºæ¯ä¸ªæµè¯å建临æ¶ç®å½ï¼
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createReadTool } from "../src/core/tools/read.ts";
describe("read tool text windows", () => {
let root: string;
let outside: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "di-code-read-root-"));
outside = await mkdtemp(join(tmpdir(), "di-code-read-outside-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
});
});
root 模æå
许读åç项ç®ç®å½ï¼outside 模æé¡¹ç®å¤é¨ç®å½ãæ¯ä¸ªæµè¯ç»æåé½å é¤å®ä»¬ï¼æµè¯ä¹é´ä¸ä¼äºç¸æ±¡æã
æµè¯æ®éææ¬
it("reads a UTF-8 text file", async () => {
await writeFile(join(root, "notes.txt"), "第ä¸è¡ second line", "utf8");
const blocks = await createReadTool(root).execute("call-1", {
path: "notes.txt",
});
expect(blocks).toEqual([
{ type: "text", text: "第ä¸è¡ second line" },
]);
});
è¿ä¸ªæµè¯è¯æç¸å¯¹è·¯å¾ä¼åºäº allowedRoot è§£æï¼ä¸æ UTF-8 ææ¬è½å¤æ£å¸¸è¿åã
æµè¯ offset å limit
it("combines offset and limit", async () => {
await writeFile(join(root, "window.txt"), "line 1 line 2 line 3 line 4", "utf8");
const blocks = await createReadTool(root).execute("call-2", {
path: "window.txt",
offset: 2,
limit: 2,
});
expect(blocks).toEqual([
{
type: "text",
text: "line 2 line 3 [1 more lines in file. Use offset=4 to continue.]",
},
]);
});
è¿éä¸ä» æ£æ¥è¿åäºåªäºè¡ï¼è¿æ£æ¥ continuation æç¤ºä¸çå©ä½è¡æ°åä¸ä¸ä¸ª offsetã
æµè¯æ ¹å¤è·¯å¾
it("rejects absolute and relative paths outside the root", async () => {
const outsideFile = join(outside, "secret.txt");
await expect(
createReadTool(root).execute("call-3a", { path: outsideFile }),
).rejects.toThrow("Path is outside the allowed root");
await expect(
createReadTool(root).execute("call-3b", {
path: relative(root, outsideFile),
}),
).rejects.toThrow("Path is outside the allowed root");
});
åä¸ä¸ªæµè¯è¦ç两ç§é鏿¹å¼ï¼
- ç´æ¥ä¼ å ¥æ ¹å¤ç»å¯¹è·¯å¾ï¼
- 使ç¨å
å«
..çç¸å¯¹è·¯å¾ã
ç®æ æä»¶æ
æä¸å建ï¼ç¨æ¥è¯ææéæ£æ¥åçå¨è¯»ååç®æ realpath() ä¹åï¼è䏿¯å
æ¥ ENOENTã
æµè¯ç¬¦å·é¾æ¥éé¸
it("rejects a symlink whose real target is outside the root", async () => {
const outsideFile = join(outside, "secret.txt");
await writeFile(outsideFile, "secret", "utf8");
const link = join(root, "link.txt");
await symlink(outsideFile, link, "file");
await expect(
createReadTool(root).execute("call-4", { path: "link.txt" }),
).rejects.toThrow("Path is outside the allowed root");
});
Windows å建符å·é¾æ¥å¯è½éè¦å¼åè
æ¨¡å¼æé¢å¤æéãæ£å¼æµè¯ä¸å¯ä»¥åªå¨æ¶å° EPERM æ EACCES æ¶è·³è¿ fixtureï¼ä½æç»ä»è¦å¨ä¸ä¸ªç¡®å®è½å¤å建符å·é¾æ¥çç¯å¢ä¸æ§è¡è¿æ¡å®å
¨åæ¯ã
æµè¯ä¸æç UTF-8 åèéå¶
it("truncates by UTF-8 bytes without returning a partial line", async () => {
const line = "ä¸".repeat(20);
await writeFile(join(root, "bytes.txt"), `${line} ${line} ${line}`, "utf8");
const blocks = await createReadTool(root, {
maxLines: 100,
maxBytes: 125,
}).execute("call-5", { path: "bytes.txt" });
const output = blocks[0]?.type === "text" ? blocks[0].text : "";
expect(output).toContain(`${line} ${line}`);
expect(output).not.toContain(`${line} ${line} ${line}`);
expect(output).toContain(
"[Showing lines 1-2 of 3 (125 bytes limit). Use offset=3 to continue.]",
);
});
ä¸ä¸ªâä¸âå 3 个 UTF-8 åèï¼20 ä¸ªå°±æ¯ 60 åèã两è¡å ä¸é´çæ¢è¡ç¬¦ä¸å ± 121 åèï¼è½å¤æ¾è¿ 125 åèï¼ç¬¬ä¸è¡å å ¥åä¼è¶ åºéå¶ï¼æä»¥åªè¿åå两è¡ã
è¿ä¸ªä¾åå¯ä»¥ç´æ¥è¯æå®ç°æ²¡æé误使ç¨å符串å符æ°ã
è¿éè¦è¦çåªäºè¡ä¸º
宿´æµè¯è¿åºå æ¬ï¼
- 空æä»¶è¿åç©ºææ¬ï¼
- æä»¶æ«å°¾æ¢è¡ä¸å¤ç®ä¸è¡ï¼
offsetè¶ è¿æä»¶æ«å°¾æ¶æéï¼offset: 0ålimit: 0被æç»ï¼- 空路å¾è¢«æç»ï¼
- æ ¹å
缺失æä»¶ä¿ç Node ç
ENOENTï¼ - å« NUL åèçæä»¶è¢«æç»ï¼
- è°ç¨åå·²ç»åæ¶æ¶æåº
Operation abortedï¼ maxLinesåmaxByteså¿ é¡»æ¯æ£æ´æ°ï¼- è¡æ°ä¸éæªæåç»åºæ£ç¡®çä¸ä¸ offsetï¼
- 第ä¸è¡å°±è¶ è¿åèä¸éæ¶æåºæç¡®é误ã
è¿äºæµè¯ä¸åªæ¯ä¸ºäºæé«è¦ççãæ¯ä¸æ¡é½å¯¹åºä¸ä¸ªè°ç¨è å¯è½çæ£éå°çè¾¹çï¼æè ä¸ä¸ªæä»¶ç³»ç»å®å ¨é£é©ã
è¿è¡éªè¯
å¨ PowerShell ä¸è¿å ¥é¡¹ç®ç®å½ï¼
Set-Location D:\pi\di-code
å è¿è¡ read å·¥å ·çå®åæµè¯ï¼
npm test --workspace @di-code/coding-agent -- --run read-tool
å½å宿´æµè¯æä»¶åºæ¶é 19 个æµè¯å¹¶å ¨é¨éè¿ï¼
Test Files 1 passed
Tests 19 passed
ç¶åæ£æ¥è¿ä¸¤ä¸ªæä»¶çæ ¼å¼ï¼
npx biome check packages/coding-agent/src/core/tools/read.ts packages/coding-agent/test/read-tool.test.ts
åè¿è¡ coding-agent ç宿´æµè¯åæå»ºï¼
npm test --workspace @di-code/coding-agent
npm run build --workspace @di-code/coding-agent
npx tsc --noEmit -p tsconfig.json
å¦ææ ¹ npm run check å 以åæä»¶çæ¢è¡æ ¼å¼å¤±è´¥ï¼è¦åºåâæ¬ç¯æ°å¢æä»¶çé®é¢âåâä¹åå·²ç»åå¨çé®é¢âãä¸è¦ä¸ºäºè®©æ£æ¥å绿èé¡ºææ ¼å¼åæ å
³æä»¶ã
常è§é误
æ ¹å¤ç¼ºå¤±æä»¶è¿å ENOENT
åå é常æ¯åªå¨ realpath(target) 忣æ¥è·¯å¾ãæ ¹å¤æä»¶ä¸å卿¶ï¼realpath() å·²ç»å
失败ã
ä¿®æ£æ¹åæ¯å
对 resolve(root, input) å¾å°ç candidate åè¯æ³å
嫿£æ¥ï¼ååå¾ç®æ çå®è·¯å¾ã
符å·é¾æ¥å¯ä»¥è¯»å°æ ¹å¤æä»¶
åå é常æ¯åªæ£æ¥äº candidateï¼æ²¡ææ£æ¥ realpath(candidate) çç»æã
è¯æ³è·¯å¾å¨æ ¹å ä¸ä»£è¡¨çå®ç®æ ä¹å¨æ ¹å ï¼ä¸¤æ¬¡æ£æ¥ä¸è½åå¹¶ã
ä½¿ç¨ startsWith 夿åç®å½
C:\work-secret ä¹ä»¥ C:\work å¼å¤´ãç®å½å
å«å
³ç³»ä¸æ¯æ®éå符串åç¼å
³ç³»ï¼åºä½¿ç¨ relative()ã
offset å°è¯»æå¤è¯»ä¸è¡
å¤é¨è¡å·ä» 1 å¼å§ï¼æ°ç»ç´¢å¼ä» 0 å¼å§ï¼
const startIndex = offset - 1;
ä¸ä¸ offset 忝ï¼
const nextOffset = endLine + 1;
æä»¶æ«å°¾å¤åºä¸ä¸ªç©ºè¡
"a b ".split(" ") ä¼äº§çæ«å°¾ç©ºå符串ãåªæå½åæä»¥æ¢è¡ç»å°¾æ¶ï¼å é¤è¿ä¸ªç±åé符产ççæåå
ç´ ã
䏿æä»¶è¶ è¿é¢ç®
ä¸è¦ç¨ line.length 计ç®åèã使ç¨ï¼
Buffer.byteLength(line, "utf8")
åæ¶è®°å¾ä»ç¬¬äºè¡å¼å§å å ¥æ¢è¡ç¬¦ç 1 个åèã
æªæåä¸ä¸ offset è·³è¿å 容
ç¨æ· limit ä¹åè¿è¦åºç¨ maxLines å maxBytesãendLine å¿
é¡»æ ¹æ®æç»çæ£è¿åçè¡æ°éç®ï¼ä¸è½ç´æ¥æ ¹æ®ç¨æ·è¯·æ±ç limit 计ç®ã
è¿åäºåè¡
å
计ç®å å
¥å®æ´ä¸è¡åçåèæ°ï¼ç¡®è®¤æ²¡æè¶
åºé¢ç®åå push()ãä¸è¦å
æ¼æ¥æ´ä¸ªå符串åæåèåçã
å·¥å ·èªå·±æé isError
read å·¥å ·åªè´è´£è¿åå
Aitishiku.com