Pi từ mã nguồnpi v1.4.0

Chương 9Phần 3: Coding agent

Công cụ và system prompt

Tám tool có sẵn, cách cắt bớt output, renderer, và cách system prompt được dựng.

  • 27 phút đọc
  • 46 đoạn code
  • 20 mục
Trong chương này20 mục
  1. Vai trò
  2. Mô hình tư duy
  3. Data model: ToolDefinition và kết quả tool
  4. Bộ tool mặc định và cách chọn tool
  5. Cắt bớt output: truncate.ts
  6. read: đọc text và ảnh
  7. bash: chạy lệnh, timeout, luồng output
  8. edit: thay thế đúng văn bản
  9. write và hàng đợi ghi theo file
  10. grep, find, ls: bộ tool chỉ đọc
  11. Renderer: phần giao diện thấy
  12. Lệnh ! của người dùng
  13. System prompt được dựng thế nào
  14. AGENTS.md, SYSTEM.md và skills
  15. “Dưới 1.000 token”: điều gì còn đúng ở v1.4.0
  16. Trace: mô hình gọi edit
  17. Quyết định thiết kế
  18. Cạm bẫy và trường hợp biên
  19. Đọc code ở đâu
  20. Tóm tắt

Vai trò#

Mô hình ngôn ngữ chỉ sinh ra văn bản. Nó đọc được file hay chạy được lệnh là nhờ harness: harness khai báo cho mô hình một danh sách tool (tên, mô tả, JSON Schema của tham số), mô hình trả lời bằng một lời gọi tool, harness chạy lời gọi đó rồi gửi lại kết quả tool. Chương này nói về hai thứ mô hình thấy đầu tiên trong mỗi request của Pi: bộ tool có sẵn và system prompt.

Các tool có sẵn nằm trong packages/coding-agent/src/core/tools/. Có tám tool: bốn tool bật mặc định là read, bash, edit, write; ba tool chỉ đọc là grep, find, ls; và powershell cho Windows (tools/index.ts:95). codemode và tool_search của Pi 1.0 không nằm ở đây mà là built-in extension, xem chương Codemode. Mỗi tool là một ToolDefinition: ngoài phần mô hình thấy, nó còn mang phần đóng góp cho system prompt và phần vẽ giao diện (renderer).

Ai dùng các tool này? AgentSession (chương AgentSession) tạo definition cho cả tám tool khi dựng runtime, bọc chúng thành AgentTool của pi-agent-core, rồi giao danh sách tool đang bật cho Agent. Vòng lặp agent (agent loop) kiểm tra đối số, gọi hook, chạy execute() và gói kết quả thành message (chương pi-agent-core). Chương này đi vào bên trong execute() của từng tool, rồi vào system-prompt.ts, nơi prompt được ghép từ tool đang bật, file AGENTS.md, skills và thư mục làm việc.

Ngoài phạm vi chương: chặn hay sửa lời gọi tool bằng extension (chương Extensions), cách session lưu message (chương Session), codemode, MCP và tool search (chương Codemode).

Mô hình tư duy#

Một tool có ba mặt hướng về ba người đọc khác nhau: mô hình, system prompt và giao diện. Kết quả của nó cũng tách làm nhiều phần, mỗi phần đi một nơi:

 coding-agent                        pi-agent-core           provider
 ToolDefinition                      AgentTool               (mô hình thấy)
 ┌──────────────────────────┐ wrap   ┌──────────────────┐    ┌──────────────┐
 │ name, label, description │ ─────▶ │ name, description│ ─▶ │ name         │
 │ parameters (TypeBox)     │        │ parameters       │    │ description  │
 │ execute(..., ctx)        │        │ execute(...)     │    │ JSON Schema  │
 │ promptSnippet ───────────┼──┐     └──────────────────┘    └──────────────┘
 │ promptGuidelines ────────┼──┴──▶ system prompt: <tools>, <rules>
 │ renderCall, renderResult ┼─────▶ giao diện (TUI)
 └──────────────────────────┘

 execute() trả về AgentToolResult:
   content            ──▶ mô hình (text, ảnh)
   details            ──▶ giao diện và file session (diff, thông tin cắt bớt)
   structuredContent  ──▶ code gọi tool theo lập trình (codemode), không tới mô hình
   isError            ──▶ mô hình thấy đây là kết quả lỗi
Một ToolDefinition, ba người đọc; một kết quả tool, bốn nơi nhận.

Các từ dùng trong phần còn lại của chương:

ToolDefinition
Kiểu của coding-agent (cũng là kiểu extension dùng với registerTool()). execute() nhận thêm ctx, ngữ cảnh extension của lời gọi: thư mục làm việc, mô hình, session.
AgentTool
Kiểu của pi-agent-core, thứ vòng lặp thực sự gọi. wrapToolDefinition() chuyển một ToolDefinition thành AgentTool.
Operations
Mỗi tool nhận một interface backend: ReadOperations, BashOperations, EditOperations... Mặc định là hệ thống file và tiến trình cục bộ; thay interface này để chạy tool qua SSH, trong container hay micro-VM.
promptSnippet, promptGuidelines
Một dòng mô tả tool trong mục <tools> của system prompt, và các gạch đầu dòng tool thêm vào mục <rules> khi nó đang bật.
Cắt bớt (truncation)
Giới hạn chung cho output tool: 2.000 dòng hoặc 50KB, cái nào chạm trước. Phần bị cắt được thay bằng một dòng hướng dẫn mô hình cách lấy tiếp.
Hàng đợi ghi theo file (file mutation queue)
Khoá theo đường dẫn thật của file, để hai lời gọi edit/write chạy song song không ghi đè lên nhau.

Data model: ToolDefinition và kết quả tool#

Định nghĩa đầy đủ của một tool nằm trong kiểu extension. Phần đầu là thứ mô hình và system prompt thấy, phần cuối là thực thi và vẽ giao diện:

ToolDefinition: phần mô hình thấy, phần prompt, phần thực thi
pi/packages/coding-agent/src/core/extensions/types.tsdòng 579–598, 649–668TypeScript
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> {	/** Tool name (used in LLM tool calls) */	name: string;	/** Human-readable label for UI */	label: string;	/** Description for LLM */	description: string;	/** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */	promptSnippet?: string;	/** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */	promptGuidelines?: string[];	/** Parameter schema (TypeBox) */	parameters: TParams;	/** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */	constrainedSampling?: false | ConstrainedSamplingConfig;	/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */	renderShell?: "default" | "self";	/** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */	prepareArguments?: (args: unknown) => Static<TParams>;	/** Execute the tool. */	execute(		toolCallId: string,		params: Static<TParams>,		signal: AbortSignal | undefined,		onUpdate: AgentToolUpdateCallback<TDetails> | undefined,		ctx: ExtensionToolContext,	): Promise<AgentToolResult<TDetails>>;	/** Custom rendering for tool call display */	renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component;	/** Custom rendering for tool result display */	renderResult?: (		result: AgentToolResult<TDetails>,		options: ToolRenderResultOptions,		theme: Theme,		context: ToolRenderContext<TState, Static<TParams>>,	) => Component;}
promptSnippet là tuỳ chọn, nhưng thiếu nó thì tool không xuất hiện trong mục <tools> của prompt (dòng 586). prepareArguments (dòng 598) là chỗ edit sửa đối số sai dạng trước khi kiểm tra schema.

Các trường lược bớt (dòng 599–648) là của Pi 1.0: outputSchema cho codemode, exposure và namespace cho MCP, executionMode để buộc chạy tuần tự. Tool có sẵn không dùng exposure hay executionMode; ba tool read, bash, powershell khai báo outputSchema.

Kết quả của execute() là một AgentToolResult, định nghĩa trong pi-agent-core:

AgentToolResult: content cho mô hình, details cho UI và log
pi/packages/agent/src/types.tsdòng 423–446TypeScript
/** Final or partial result produced by a tool. */export interface AgentToolResult<T = JsonValue | undefined> {	/** Text or image content returned to the model. */	content: (TextContent | ImageContent)[];	/** Arbitrary structured details for logs or UI rendering. */	details: T;	/**	 * Machine-readable result matching the tool's `outputSchema`, for programmatic callers. Not sent	 * to the model; `content` remains the model-facing result.	 */	structuredContent?: JsonValue;	/** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */	usage?: Usage;	/**	 * Report a failure without throwing. The model sees `content` as an error result, like a thrown	 * error, but `details` and `structuredContent` are kept for the UI and programmatic callers.	 */	isError?: boolean;	/**	 * Hint that the agent should stop after the current tool batch.	 * Early termination only happens when every finalized tool result in the batch sets this to true.	 */	terminate?: boolean;}
isError cho phép báo lỗi mà không throw: mô hình vẫn thấy một kết quả lỗi, còn details được giữ cho giao diện. bash dùng đường này khi lệnh trả mã khác 0.

Khi lời gọi xong, vòng lặp gói kết quả thành một ToolResultMessage của pi-ai. Đây là thứ được nối vào transcript và ghi vào file session:

ToolResultMessage: message nằm trong transcript
pi/packages/ai/src/types.tsdòng 633–650TypeScript
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true	? {			role: "toolResult";			toolCallId: string;			toolName: string;			content: (TextContent | ImageContent)[]; // Supports text and images			details?: JsonRepresentation<TDetails>;			/** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */			usage?: Usage;			/** Calls this tool made to other tools. Kept for the session record; not sent to the model. */			nestedCalls?: NestedToolCalls;			isError: boolean;			/** Unix timestamp in milliseconds when the result was created. */			timestamp: number;			/** Milliseconds the tool's execution took, measured with a monotonic clock. Absent for legacy results. */			durationMs?: number;		}	: never;

Để ý là structuredContent không có trong message: createToolResultMessage() chỉ chép content, details, usage, isError và durationMs (agent-loop.ts:940–954). Đến lượt mình, adapter của provider chỉ gửi đi content và cờ lỗi của message. Với Anthropic Messages API, khối tool_result được dựng từ đúng hai trường đó (anthropic-messages.ts:1339–1346). details vì thế không tốn token nào; nó chỉ phục vụ giao diện, file session và extension.

Ví dụ minh hoạ: một ToolResultMessage của edit như được ghi vào session (rút gọn)
{
  "role": "toolResult",
  "toolCallId": "toolu_01",
  "toolName": "edit",
  "content": [{ "type": "text", "text": "Successfully replaced 1 block(s) in src/server.ts." }],
  "details": {
    "diff": "-12 const port = 3000;\n+12 const port = Number(process.env.PORT ?? 3000);",
    "patch": "--- src/server.ts\n+++ src/server.ts\n@@ ...",
    "firstChangedLine": 12
  },
  "isError": false,
  "durationMs": 4,
  "timestamp": 1761100000000
}
Mô hình chỉ nhận dòng “Successfully replaced...”. Diff nằm trong details cho giao diện vẽ.

Bộ tool mặc định và cách chọn tool#

tools/index.ts liệt kê tám tên tool và cung cấp các bộ dựng sẵn cho SDK: bộ “coding” bốn tool và bộ chỉ đọc bốn tool.

Hai bộ dựng sẵn: coding và chỉ đọc
pi/packages/coding-agent/src/core/tools/index.tsdòng 164–180TypeScript
export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {	return [		createReadToolDefinition(cwd, options?.read),		createBashToolDefinition(cwd, options?.bash),		createEditToolDefinition(cwd, options?.edit),		createWriteToolDefinition(cwd, options?.write),	];}export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {	return [		createReadToolDefinition(cwd, options?.read),		createGrepToolDefinition(cwd, options?.grep),		createFindToolDefinition(cwd, options?.find),		createLsToolDefinition(cwd, options?.ls),	];}

Bộ tool lúc khởi động do createAgentSession() quyết định. Mặc định là DEFAULT_TOOL_NAMES (settings-manager.ts:85), trừ khi setting defaultTools đổi nó. Tuỳ chọn tools của SDK (cờ --tools của CLI) hoặc thay hẳn danh sách, hoặc chỉ cộng trừ khi mọi phần tử đều có dạng +name/-name; excludeTools lọc cuối cùng:

createAgentSession(): chọn tên tool bật lúc khởi động
pi/packages/coding-agent/src/core/sdk.tsdòng 280–294TypeScript
const toolListError = options.tools ? getToolListError(options.tools) : undefined;if (toolListError) throw new Error(`Invalid tools option: ${toolListError}`);const defaultToolNames = options.noTools ? [] : (settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES);// A `tools` list of only `+name`/`-name` entries changes the default selection instead of// replacing it, like the `defaultTools` setting.const toolModifiers = options.tools?.some(isToolModifier) ? options.tools : undefined;const selectedToolNames = toolModifiers ? applyToolModifiers(defaultToolNames, toolModifiers) : options.tools;const allowedToolNames = toolModifiers	? options.noTools === "all"		? selectedToolNames		: undefined	: (options.tools ?? (options.noTools === "all" ? [] : undefined));const excludedToolNames = options.excludeTools;const isExcludedTool = excludedToolNames ? createToolNameMatcher(excludedToolNames) : undefined;const initialActiveToolNames = (selectedToolNames ?? defaultToolNames).filter((name) => !isExcludedTool?.(name));

Ở phía AgentSession, _buildRuntime() luôn tạo definition cho cả tám tool, truyền vào các setting liên quan (tự thu nhỏ ảnh, tiền tố lệnh shell, đường dẫn shell), nhưng chỉ bật những tên được chọn:

_buildRuntime(): tạo đủ tám definition, bật bốn
pi/packages/coding-agent/src/core/agent-session.tsdòng 3671–3684, 3710–3717TypeScript
const autoResizeImages = this.settingsManager.getImageAutoResize();const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();const shellPath = this.settingsManager.getShellPath();const baseToolDefinitions = this._baseToolsOverride	? Object.fromEntries(			Object.entries(this._baseToolsOverride).map(([name, tool]) => [				name,				createToolDefinitionFromAgentTool(tool),			]),		)	: createAllToolDefinitions(this._cwd, {			read: { autoResizeImages },			bash: { commandPrefix: shellCommandPrefix, shellPath },		});const defaultActiveToolNames = this._baseToolsOverride	? Object.keys(this._baseToolsOverride)	: ["read", "bash", "edit", "write"];const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;this._refreshToolRegistry({	activeToolNames: baseActiveToolNames,	includeAllExtensionTools: options.includeAllExtensionTools,});

Sau đó _refreshToolRegistry() gộp tool có sẵn với tool của extension và SDK (tool extension cùng tên sẽ thay tool có sẵn), gom promptSnippet và promptGuidelines của từng tool vào hai map cho system prompt, rồi bọc tất cả bằng wrapRegisteredTools() (agent-session.ts:3583–3618). Hàm bọc rất mỏng:

wrapToolDefinition(): ToolDefinition thành AgentTool
pi/packages/coding-agent/src/core/tools/tool-definition-wrapper.tsdòng 8–30TypeScript
export function wrapToolDefinition<TDetails = unknown>(	definition: ToolDefinition<any, TDetails>,	ctxFactory?: ToolContextFactory,): AgentTool<any, TDetails> {	return {		name: definition.name,		label: definition.label,		description: definition.description,		parameters: definition.parameters,		outputSchema: definition.outputSchema,		constrainedSampling: definition.constrainedSampling,		prepareArguments: definition.prepareArguments,		executionMode: definition.executionMode,		execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionToolContext) =>			definition.execute(				toolCallId,				params,				signal,				onUpdate,				ctx ?? (ctxFactory?.(toolCallId, signal) as ExtensionToolContext),			),	};}
ctxFactory do ExtensionRunner.createToolContext() cung cấp (extensions/wrapper.ts:17–21), nên tool có sẵn và tool extension nhận cùng một loại ngữ cảnh.

Nhờ ctx này, mọi tool có sẵn tính đường dẫn theo ctx?.cwd || cwd tại thời điểm gọi chứ không chỉ theo cwd lúc tạo tool (ví dụ edit.ts:161), và read biết mô hình hiện tại có nhận ảnh hay không.

Từ dòng lệnh, --tools read,grep,find,ls cho một bộ tool không ghi được file (docs/cli.md:114); --tools +codemode,-write cộng trừ trên bộ mặc định; --exclude-tools tắt theo mẫu; --no-builtin-tools tắt tool có sẵn nhưng giữ tool extension; --no-tools tắt hết (docs/cli.md:119–128).

Cắt bớt output: truncate.ts#

Vấn đề: output tool là thứ ngốn cửa sổ ngữ cảnh (context window) nhiều nhất. Một lệnh cat trên file log 30MB, hay một npm test in mười nghìn dòng, đủ làm request kế tiếp vượt giới hạn của mô hình. Và đã vào transcript thì output đó được gửi lại ở mọi lượt (turn) sau. Pi đặt một bộ giới hạn chung cho mọi tool, trong một file:

Hai giới hạn độc lập: 2.000 dòng và 50KB
pi/packages/coding-agent/src/core/tools/truncate.tsdòng 1–13TypeScript
/** * Shared truncation utilities for tool outputs. * * Truncation is based on two independent limits - whichever is hit first wins: * - Line limit (default: 2000 lines) * - Byte limit (default: 50KB) * * Never returns partial lines (except bash tail truncation edge case). */export const DEFAULT_MAX_LINES = 2000;export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KBexport const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line

Có hai hướng cắt. truncateHead() giữ phần đầu, dùng cho read, grep, find, ls, vì phần đầu của file hay của danh sách kết quả là phần có ích nhất. truncateTail() giữ phần cuối, dùng cho bash, vì lỗi và kết quả cuối cùng của một lệnh nằm ở cuối output.

Không bao giờ trả về nửa dòng, trừ một ngoại lệ. Khi cắt đầu mà riêng dòng đầu tiên đã lớn hơn 50KB (một file JSON minified chẳng hạn), hàm trả chuỗi rỗng kèm firstLineExceedsLimit: true; read khi đó trả cho mô hình một gợi ý dùng sed -n ... | head -c qua bash. Khi cắt đuôi mà dòng cuối đã lớn hơn giới hạn, hàm lấy phần cuối của chính dòng đó, cắt đúng ranh giới ký tự UTF-8:

truncateTail(): đi ngược từ cuối, chỉ cắt nửa dòng khi buộc phải vậy
pi/packages/coding-agent/src/core/tools/truncate.tsdòng 168–170, 193–218TypeScript
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {	const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;	const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;	// Work backwards from the end	const outputLinesArr: string[] = [];	let outputBytesCount = 0;	let truncatedBy: "lines" | "bytes" = "lines";	let lastLinePartial = false;	for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {		const line = lines[i];		const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline		if (outputBytesCount + lineBytes > maxBytes) {			truncatedBy = "bytes";			// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,			// take the end of the line (partial)			if (outputLinesArr.length === 0) {				const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);				outputLinesArr.unshift(truncatedLine);				outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");				lastLinePartial = true;			}			break;		}		outputLinesArr.unshift(line);		outputBytesCount += lineBytes;	}

TruncationResult ghi đủ thông tin để dựng thông báo: tổng số dòng và byte, số dòng và byte giữ lại, giới hạn nào bị chạm (truncate.ts:15–38). Mỗi tool dùng nó để nối vào output một dòng hướng dẫn hành động, ví dụ [Showing lines 1-2000 of 8412. Use offset=2001 to continue.] cho read, hay [Showing lines 6413-8412 of 8412. Full output: /tmp/pi-bash-….log] cho bash. Thông báo không chỉ nói là đã cắt mà nói luôn bước tiếp theo, nên mô hình tự lấy được phần còn lại mà harness không phải can thiệp. File còn có truncateLine() cắt một dòng xuống 500 ký tự cho grep (truncate.ts:268), và truncateMiddle() giữ đầu và đuôi “như Codex”, hiện chỉ dùng cho output của tool MCP (mcp/tools.ts:185).

read: đọc text và ảnh#

Schema của read chỉ có ba tham số. Phần đóng góp cho system prompt nằm ngay cạnh, cùng file:

Schema TypeBox và đóng góp prompt của read
pi/packages/coding-agent/src/core/tools/read.tsdòng 14–23TypeScript
const readSchema = Type.Object({	path: Type.String({ description: "Path to the file to read (relative or absolute)" }),	offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),	limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),});export const readToolSystemPromptContribution = {	snippet: "Read file contents",	guidelines: ["Use read to examine files instead of cat or sed."],} as const;

Mô tả mà mô hình đọc được dựng từ chính các hằng số cắt bớt, nên con số trong mô tả không thể lệch khỏi hành vi:

read: phần mô hình thấy
pi/packages/coding-agent/src/core/tools/read.tsdòng 93–101TypeScript
return {	name: "read",	label: "read",	description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,	promptSnippet: readToolSystemPromptContribution.snippet,	promptGuidelines: [...readToolSystemPromptContribution.guidelines],	parameters: readSchema,	outputSchema: readOutputSchema,	constrainedSampling: { type: "json_schema", strict: "prefer" },

constrainedSampling xin provider dùng chế độ strict, tức là sinh đối số bị ràng buộc theo JSON Schema, khi API có hỗ trợ; "prefer" là muốn có nhưng không bắt buộc, khác "require" (pi-ai types.ts:745–756). Bốn tool mặc định đều bật nó; grep, find, ls thì không.

Phân giải đường dẫn#

Mọi tool file đều đi qua path-utils.ts. resolveToCwd() gọi resolvePath() với hai tuỳ chọn: bỏ ký tự @ ở đầu (mô hình hay chép nguyên cú pháp @file của người dùng) và đổi các biến thể khoảng trắng Unicode thành dấu cách thường. normalizePath() còn mở rộng ~ và nhận URL file://:

normalizePath() và resolvePath(): đường dẫn tuyệt đối được giữ nguyên
pi/packages/coding-agent/src/utils/paths.tsdòng 76–107TypeScript
export function normalizePath(input: string, options: PathInputOptions = {}): string {	let normalized = options.trim ? input.trim() : input;	if (options.normalizeUnicodeSpaces) {		normalized = normalized.replace(UNICODE_SPACES, " ");	}	if (options.stripAtPrefix && normalized.startsWith("@")) {		normalized = normalized.slice(1);	}	if (process.platform === "win32") {		normalized = normalizeWindowsShellPath(normalized);	}	if (options.expandTilde ?? true) {		const home = options.homeDir ?? homedir();		if (normalized === "~") return home;		if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {			return join(home, normalized.slice(2));		}	}	if (/^file:\/\//.test(normalized)) {		return fileURLToPath(normalized);	}	return normalized;}export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string {	const normalized = normalizePath(input, options);	const normalizedBaseDir = normalizePath(baseDir);	return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized);}

Dòng 106 đáng chú ý: đường dẫn tuyệt đối được dùng nguyên, đường dẫn tương đối được nối vào thư mục làm việc, và .. không bị chặn. Không có sandbox nào ở tầng tool. Riêng read thử thêm vài biến thể khi file không tồn tại, vì tên ảnh chụp màn hình trên macOS chứa ký tự mà người dùng không gõ ra được:

resolveReadPathAsync(): khoảng trắng hẹp trước AM/PM, dạng NFD, dấu nháy cong
pi/packages/coding-agent/src/core/tools/path-utils.tsdòng 86–118TypeScript
export async function resolveReadPathAsync(filePath: string, cwd: string): Promise<string> {	const resolved = resolveToCwd(filePath, cwd);	if (await pathExists(resolved)) {		return resolved;	}	// Try macOS AM/PM variant (narrow no-break space before AM/PM)	const amPmVariant = tryMacOSScreenshotPath(resolved);	if (amPmVariant !== resolved && (await pathExists(amPmVariant))) {		return amPmVariant;	}	// Try NFD variant (macOS stores filenames in NFD form)	const nfdVariant = tryNFDVariant(resolved);	if (nfdVariant !== resolved && (await pathExists(nfdVariant))) {		return nfdVariant;	}	// Try curly quote variant (macOS uses U+2019 in screenshot names)	const curlyVariant = tryCurlyQuoteVariant(resolved);	if (curlyVariant !== resolved && (await pathExists(curlyVariant))) {		return curlyVariant;	}	// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")	const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);	if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) {		return nfdCurlyVariant;	}	return resolved;}

File text#

read với file text: offset, limit, rồi truncateHead
pi/packages/coding-agent/src/core/tools/read.tsdòng 157–165, 169–194TypeScript
const allLines = textContent.split("\n");const totalFileLines = allLines.length;// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.const startLine = offset ? Math.max(0, offset - 1) : 0;const startLineDisplay = startLine + 1;// Check if offset is out of bounds.if (startLine >= allLines.length) {	throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);}if (limit !== undefined) {	const endLine = Math.min(startLine + limit, allLines.length);	selectedContent = allLines.slice(startLine, endLine).join("\n");	userLimitedLines = endLine - startLine;} else {	selectedContent = allLines.slice(startLine).join("\n");}// Apply truncation, respecting both line and byte limits.const truncation = truncateHead(selectedContent);let outputText: string;if (truncation.firstLineExceedsLimit) {	// First line alone exceeds the byte limit. Point the model at a bash fallback.	const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));	outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;	details = { truncation };} else if (truncation.truncated) {	// Truncation occurred. Build an actionable continuation notice.	const endLineDisplay = startLineDisplay + truncation.outputLines - 1;	const nextOffset = endLineDisplay + 1;	outputText = truncation.content;	if (truncation.truncatedBy === "lines") {		outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;	} else {		outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;	}	details = { truncation };
offset đánh số từ 1. limit của mô hình được áp trước; giới hạn 2.000 dòng/50KB áp sau, nên limit: 5000 vẫn chỉ trả về tối đa 2.000 dòng kèm thông báo đọc tiếp.

Output không kèm số dòng. Thứ mô hình nhận đúng là nội dung file, nên đoạn nó chép sang oldText của edit cũng là văn bản thật của file, không phải gỡ tiền tố số dòng ra trước.

Ảnh#

read không đoán loại file theo đuôi. detectSupportedImageMimeTypeFromFile() đọc 4.100 byte đầu và nhận diện JPEG, PNG (bỏ qua PNG động), GIF, WebP, BMP theo chữ ký nhị phân (utils/mime.ts:6–23). Có MIME thì đi nhánh ảnh:

read với ảnh: chuẩn hoá, thu nhỏ, kèm một dòng text
pi/packages/coding-agent/src/core/tools/read.tsdòng 129–152TypeScript
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;let content: (TextContent | ImageContent)[];let details: ReadToolDetails | undefined;const nonVisionImageNote = getNonVisionImageNote(ctx?.model);if (mimeType) {	// Read image as binary.	const buffer = await ops.readFile(absolutePath);	const processed = await processImage(buffer, mimeType, {		autoResizeImages,		resizeOptions: ctx?.model?.inputLimits?.images?.resize ?? fallbackResizeOptions,	});	if (!processed.ok) {		let textNote = `Read image file [${mimeType}]\n${processed.message}`;		if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;		content = [{ type: "text", text: textNote }];	} else {		let textNote = `Read image file [${processed.mimeType}]`;		if (processed.hints.length > 0) textNote += `\n${processed.hints.join("\n")}`;		if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`;		content = [			{ type: "text", text: textNote },			{ type: "image", data: processed.data, mimeType: processed.mimeType },		];	}
Giới hạn thu nhỏ lấy từ metadata của mô hình nếu có (inputLimits.images.resize), nếu không thì dùng mặc định.

processImage() đổi BMP sang PNG, rồi thu nhỏ về tối đa 2000×2000 điểm ảnh và 4,5MB base64, “dưới giới hạn 5MB của Anthropic” (image-resize-core.ts:31–39). Khi ảnh bị thu nhỏ, dòng text đi kèm báo kích thước gốc và hệ số nhân toạ độ (image-resize.ts:115), để mô hình nói “nút ở toạ độ (840, 220)” thì toạ độ đó vẫn đúng trên ảnh gốc. Nếu mô hình hiện tại không nhận ảnh, text kèm thêm một câu báo ảnh sẽ bị bỏ khỏi request (read.ts:79–84). Cuối cùng, structuredContent của read là chuỗi text hoặc một đối tượng ảnh cho script codemode (read.ts:216).

bash: chạy lệnh, timeout, luồng output#

Schema của bash: một lệnh và một timeout tuỳ chọn
pi/packages/coding-agent/src/core/tools/bash.tsdòng 40–48TypeScript
const bashSchema = Type.Object({	command: Type.String({ description: "Shell command to execute" }),	timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),});export const bashToolSystemPromptContribution = {	snippet: "Execute bash commands (ls, grep, find, etc.)",	guidelines: ["You can inspect PI_* environment variables for current model and session details."],} as const;
Không có timeout mặc định. timeout tính bằng giây, phải là số dương hữu hạn, tối đa khoảng 2.147.483 giây, giới hạn của setTimeout (bash.ts:27–38).

Mô tả mô hình thấy báo trước rằng output bị cắt còn 2.000 dòng cuối hoặc 50KB và output đầy đủ được lưu ra file tạm (bash.ts:255). bash và powershell dùng chung một hàm dựng, createShellToolDefinition(), chỉ khác cấu hình.

Sinh tiến trình#

Backend mặc định là createLocalShellOperations(). Shell được chọn bởi getShellConfig(): shellPath trong settings nếu có, trên Unix thì /bin/bash, rồi bash trên PATH, cuối cùng là sh; trên Windows thì tìm Git Bash (utils/shell.ts:86–139). Lệnh chạy dưới dạng bash -c "<lệnh>":

spawn(): tiến trình tách riêng, stdin bị bỏ qua
pi/packages/coding-agent/src/core/tools/bash.tsdòng 104–128TypeScript
const shellConfig = resolveShellConfig();try {	await fsAccess(cwd, constants.F_OK);} catch {	throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`);}const commandFromStdin = shellConfig.commandTransport === "stdin";const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {	cwd,	detached: process.platform !== "win32",	env: env ?? getShellEnv(),	stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],	windowsHide: true,});if (commandFromStdin) {	child.stdin?.on("error", () => {});	child.stdin?.end(command);}if (child.pid) trackDetachedChildPid(child.pid);let timedOut = false;let timeoutHandle: NodeJS.Timeout | undefined;const onAbort = () => {	if (child.pid) killProcessTree(child.pid);};
detached: true trên Unix làm shell thành trưởng một nhóm tiến trình (process group) mới, nhờ vậy về sau giết được cả cây. stdin là "ignore": lệnh nào chờ nhập từ bàn phím sẽ nhận EOF ngay chứ không treo.
Timeout, luồng stdout/stderr và mã thoát
pi/packages/coding-agent/src/core/tools/bash.tsdòng 131–158TypeScript
// Set timeout if provided.if (timeoutMs !== undefined) {	timeoutHandle = setTimeout(() => {		timedOut = true;		if (child.pid) killProcessTree(child.pid);	}, timeoutMs);}// Stream stdout and stderr.child.stdout?.on("data", onData);child.stderr?.on("data", onData);// Handle abort signal by killing the entire process tree.if (signal) {	if (signal.aborted) onAbort();	else signal.addEventListener("abort", onAbort, { once: true });}// Handle shell spawn errors and wait for the process to terminate without hanging// on inherited stdio handles held by detached descendants.const exitCode = await waitForChildProcess(child);if (signal?.aborted) {	throw new Error("aborted");}if (timedOut) {	throw new Error(`timeout:${timeout}`);}// A signal-killed shell has no exit code. Use the standard shell convention so// callers do not mistake the termination for a successful command.const signalCode = child.signalCode;return { exitCode: exitCode ?? (signalCode ? 128 + (osConstants.signals[signalCode] ?? 0) : 1) };

stdout và stderr đổ chung vào một onData, nên mô hình thấy chúng xen kẽ theo thứ tự Pi nhận được, như trong một terminal. Shell bị giết bằng tín hiệu thì không có mã thoát; dòng 158 đổi thành 128 cộng số hiệu tín hiệu theo quy ước của shell, để không bị hiểu nhầm là thành công. waitForChildProcess() giải quyết một ca khó: shell đã thoát nhưng một tiến trình con chạy nền vẫn giữ pipe output. Hàm không đóng pipe theo một hạn cố định mà chờ pipe im lặng 100ms, và mỗi chunk mới lại đặt lại đồng hồ (child-process.ts:39–48).

Khi hết giờ hoặc bị huỷ, Pi giết cả nhóm tiến trình chứ không chỉ shell, vì npm test có thể đã sinh ra cả chục tiến trình con:

killProcessTree(): SIGKILL cho cả nhóm tiến trình
pi/packages/coding-agent/src/utils/shell.tsdòng 217–221, 238–250TypeScript
/** * Kill a process and all its children (cross-platform) */export function killProcessTree(pid: number): void {	if (process.platform === "win32") {	} else {		// Use SIGKILL on Unix/Linux/Mac		try {			process.kill(-pid, "SIGKILL");		} catch {			// Fallback to killing just the child if process group kill fails			try {				process.kill(pid, "SIGKILL");			} catch {				// Process already dead			}		}	}
PID âm trong process.kill(-pid) nghĩa là cả nhóm. Trên Windows, Pi gọi taskkill /F /T từ System32. Các PID tách riêng còn được ghi nhớ để giết nốt khi chính Pi thoát (shell.ts:196–215).

Môi trường của lệnh#

getShellEnv() sao chép môi trường của Pi và thêm ~/.pi/agent/bin (nơi Pi tải rg và fd về) vào đầu PATH (shell.ts:171–183). Mỗi lời gọi còn nhận vài biến PI_* mô tả session hiện tại, khớp với guideline “You can inspect PI_* environment variables” trong prompt:

resolveSpawnContext(): biến PI_* và spawnHook
pi/packages/coding-agent/src/core/tools/bash.tsdòng 193–211TypeScript
const env = { ...getShellEnv() };delete env.PI_SESSION_ID;delete env.PI_SESSION_FILE;delete env.PI_PROVIDER;delete env.PI_MODEL;delete env.PI_REASONING_LEVEL;if (exposeSessionEnvironment && ctx) {	const model = ctx.model;	env.PI_SESSION_ID = ctx.sessionManager.getSessionId();	const sessionFile = ctx.sessionManager.getSessionFile();	if (sessionFile) env.PI_SESSION_FILE = sessionFile;	if (model) {		env.PI_PROVIDER = model.provider;		env.PI_MODEL = model.id;	}	if (ctx.thinkingLevel) env.PI_REASONING_LEVEL = ctx.thinkingLevel;}const baseContext: BashSpawnContext = { command, cwd, env };return spawnHook ? spawnHook(baseContext) : baseContext;
Các biến cũ bị xoá trước (dòng 194–198) để lệnh không nhận nhầm giá trị của một Pi khác đã khởi động tiến trình này. spawnHook cho extension sửa lệnh, thư mục hay môi trường trước khi chạy; setting shellCommandPrefix nối thêm một dòng vào trước mọi lệnh (bash.ts:268).

Luồng output và file tạm#

Lệnh chạy lâu thì giao diện cần thấy output ngay, còn bộ nhớ của Pi không được phình theo output. OutputAccumulator giải quyết cả hai: giải mã UTF-8 theo luồng, chỉ giữ trong bộ nhớ một đoạn đuôi cỡ hai lần giới hạn, và chuyển sang ghi file tạm ngay khi output vượt giới hạn:

OutputAccumulator: đuôi trong bộ nhớ, toàn bộ ra file
pi/packages/coding-agent/src/core/tools/output-accumulator.tsdòng 64–78, 239–256TypeScript
append(data: Buffer): void {	if (this.finished) {		throw new Error("Cannot append to a finished output accumulator");	}	this.totalRawBytes += data.length;	this.appendDecodedText(this.decoder.decode(data, { stream: true }));	if (this.tempFileStream || this.shouldUseTempFile()) {		this.ensureTempFile();		this.tempFileStream?.write(data);	} else if (data.length > 0) {		this.rawChunks.push(data);	}}private shouldUseTempFile(): boolean {	return (		this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines	);}private ensureTempFile(): void {	if (this.tempFilePath) {		return;	}	const { path, stream } = createOutputFileStream(this.tempFilePrefix, ".log");	this.tempFilePath = path;	this.tempFileStream = stream;	for (const chunk of this.rawChunks) {		this.tempFileStream.write(chunk);	}	this.rawChunks = [];}
Chừng nào output còn nhỏ, các chunk thô nằm trong bộ nhớ. Lần đầu vượt giới hạn, mọi chunk đã có được ghi ra file và từ đó output đi thẳng vào file. File nằm trong thư mục tạm của hệ điều hành, tên dạng pi-bash-<16 ký tự hex>.log, quyền 0600 vì output có thể chứa dữ liệu riêng (output-files.ts:14–20).

Mỗi chunk đến thì tool đánh dấu “có dữ liệu mới” và hẹn một lần cập nhật, tối đa mười lần mỗi giây (BASH_UPDATE_THROTTLE_MS = 100, renderers/bash.ts:19). Mỗi lần cập nhật gửi qua onUpdate một ảnh chụp đã cắt đuôi; vòng lặp biến nó thành sự kiện tool_execution_update cho giao diện:

Gộp các chunk thành tối đa mười cập nhật mỗi giây
pi/packages/coding-agent/src/core/tools/bash.tsdòng 303–316, 322–326TypeScript
const scheduleOutputUpdate = () => {	if (!onUpdate) return;	updateDirty = true;	const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);	if (delay <= 0) {		clearUpdateTimer();		emitOutputUpdate();		return;	}	updateTimer ??= setTimeout(() => {		updateTimer = undefined;		emitOutputUpdate();	}, delay);};const handleData = (data: Buffer) => {	if (!acceptingOutput) return;	output.append(data);	scheduleOutputUpdate();};

Kết quả#

Ba loại kết thúc: thành công, mã thoát khác 0, huỷ hoặc hết giờ
pi/packages/coding-agent/src/core/tools/bash.tsdòng 371–382, 386–408TypeScript
} catch (err) {	const snapshot = await finishOutput();	const { text } = formatOutput(snapshot, "");	if (err instanceof Error && err.message === "aborted") {		throw new Error(appendStatus(text, "Command aborted"));	}	if (err instanceof Error && err.message.startsWith("timeout:")) {		const timeoutSecs = err.message.split(":")[1];		throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`));	}	throw err;}if (exitCode === null) {	throw new Error(appendStatus(outputText, "Command terminated without an exit code"));}const wallTimeSeconds = Math.round((performance.now() - startedAt) / 100) / 10;const fullOutput = await output.readFullOutput(STRUCTURED_OUTPUT_MAX_BYTES);const structuredContent: BashToolOutput = {	output: fullOutput.content,	truncated: fullOutput.truncated,	...(fullOutput.truncated && snapshot.fullOutputPath		? { full_output_path: snapshot.fullOutputPath }		: {}),	exit_code: exitCode,	wall_time_seconds: wallTimeSeconds,};if (exitCode !== 0) {	return {		content: [{ type: "text", text: appendStatus(outputText, `Command exited with code ${exitCode}`) }],		details,		structuredContent,		isError: true,	};}return { content: [{ type: "text", text: outputText }], details, structuredContent };

Ba loại kết thúc được báo theo ba cách khác nhau. Mã thoát khác 0 là kết quả bình thường có isError: true: mô hình thấy output kèm dòng Command exited with code N, còn details (thông tin cắt bớt, đường dẫn file đầy đủ) vẫn tới giao diện. Huỷ và hết giờ thì throw một Error mà message đã chứa phần output thu được; vòng lặp agent bắt lỗi đó và biến thành kết quả lỗi (agent-loop.ts:855–863). structuredContent cho codemode có output tới 1MB (giữ đầu và đuôi), mã thoát và thời gian chạy.

powershell dùng đúng bộ máy này với pwsh.exe hoặc powershell.exe, chèn trước mỗi lệnh một câu đặt mã hoá output là UTF-8 (powershell.ts:16–37), và chỉ chạy trên Windows (shell.ts:144–147).

edit: thay thế đúng văn bản#

edit không nhận số dòng, cũng không nhận patch. Mô hình đưa đoạn văn bản cũ, chép nguyên văn, cùng đoạn thay thế; một lời gọi có thể chứa nhiều cặp như vậy cho cùng một file:

Schema của edit và bốn guideline cho system prompt
pi/packages/coding-agent/src/core/tools/edit.tsdòng 21–51TypeScript
const replaceEditSchema = Type.Object(	{		oldText: Type.String({			description:				"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",		}),		newText: Type.String({ description: "Replacement text for this targeted edit." }),	},	{},);const editSchema = Type.Object(	{		path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),		edits: Type.Array(replaceEditSchema, {			description:				"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",		}),	},	{},);export const editToolSystemPromptContribution = {	snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",	guidelines: [		"Use edit for precise changes (edits[].oldText must match exactly)",		"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",		"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",		"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",	],} as const;

Quy tắc xuất hiện ở ba chỗ: mô tả từng trường (dòng 25, 37), mô tả tool (edit.ts:152), và guidelines trong system prompt. Changelog cho thấy schema này được chỉnh dần để giảm lời gọi sai: bản 0.63.0 cho phép nhiều edit trong một lời gọi (CHANGELOG.md:3048), bản 0.63.2 bỏ hẳn dạng một edit lẻ vì việc trộn hai dạng khiến mô hình gọi sai rồi thử lại liên tục (CHANGELOG.md:2987).

Sửa đối số trước khi kiểm tra schema#

prepareEditArguments(): nhận cả những dạng đối số sai đã gặp thật
pi/packages/coding-agent/src/core/tools/edit.tsdòng 103–134TypeScript
function prepareEditArguments(input: unknown): EditToolInput {	if (!input || typeof input !== "object") {		return input as EditToolInput;	}	const args = input as Record<string, unknown>;	// Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array.	// Others send a single edit object instead of a one-element edits array.	if (typeof args.edits === "string") {		try {			const parsed = JSON.parse(args.edits);			if (Array.isArray(parsed)) {				args.edits = parsed;			} else if (isSingleEditInput(parsed)) {				args.edits = [parsed];			}		} catch {}	} else if (isSingleEditInput(args.edits)) {		args.edits = [args.edits];	}	const legacy = args as LegacyEditToolInput;	if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {		return args as EditToolInput;	}	const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];	edits.push({ oldText: legacy.oldText, newText: legacy.newText });	const { oldText: _oldText, newText: _newText, ...rest } = legacy;	return { ...rest, edits } as EditToolInput;}

Vòng lặp gọi prepareArguments ngay trước validateToolArguments() (agent-loop.ts:736–737). Hàm này đỡ ba dạng sai: edits là chuỗi JSON thay vì mảng (comment ghi tên Opus 4.6 và GLM-5.1), edits là một đối tượng đơn, và dạng cũ oldText/newText ở cấp cao nhất, vẫn còn trong các session cũ được mở lại (CHANGELOG.md:2972). Sửa ở đây rẻ hơn nhiều so với trả lỗi validation để mô hình gọi lại.

Thực thi#

execute(): đọc, chuẩn hoá, áp edit, khôi phục, ghi, tạo diff
pi/packages/coding-agent/src/core/tools/edit.tsdòng 160–170, 185–211TypeScript
const { path, edits } = validateEditInput(input);const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);return withFileMutationQueue(absolutePath, async () => {	// Do not reject from an abort event listener here: that would release the	// mutation queue while an in-flight filesystem operation may still finish.	// Checking signal.aborted after each await observes the same aborts while	// keeping the queue locked until the current operation has settled.	const throwIfAborted = (): void => {		if (signal?.aborted) throw new Error("Operation aborted");	};	// Read the file.	const buffer = await ops.readFile(absolutePath);	const rawContent = buffer.toString("utf-8");	throwIfAborted();	// Strip BOM before matching. The model will not include an invisible BOM in oldText.	const { bom, text: content } = splitBom(rawContent);	const originalEnding = detectLineEnding(content);	const normalizedContent = normalizeToLF(content);	const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);	throwIfAborted();	const finalContent = bom + restoreLineEndings(newContent, originalEnding);	await ops.writeFile(absolutePath, finalContent);	throwIfAborted();	const diffResult = generateDiffString(baseContent, newContent);	const patch = generateUnifiedPatch(path, baseContent, newContent);	return {		content: [			{				type: "text",				text: `Successfully replaced ${edits.length} block(s) in ${path}.`,			},		],		details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },	};

Mọi việc diễn ra trong hàng đợi của file (dòng 163): kiểm tra quyền đọc và ghi, đọc file, tách BOM (mô hình không bao giờ chép được một BOM vô hình vào oldText), ghi nhớ kiểu xuống dòng rồi chuẩn hoá về LF, áp các edit, khôi phục CRLF và BOM, ghi file. Nhờ vậy một file CRLF vẫn là CRLF sau khi sửa dù mô hình chỉ viết LF; kiểu xuống dòng được xác định theo lần xuống dòng đầu tiên trong file (edit-diff.ts:11–17). Comment ở dòng 164–167 giải thích vì sao huỷ được kiểm tra sau mỗi await thay vì reject từ listener: reject sớm sẽ nhả khoá hàng đợi trong khi một thao tác ghi file có thể vẫn đang chạy.

Khớp chính xác, rồi khớp mờ#

Phần lõi là applyEditsToNormalizedContent(). Mọi edit được tìm trên cùng một nội dung gốc, không phải trên kết quả của edit trước, rồi được áp từ cuối file lên đầu để vị trí của các edit chưa áp không bị xê dịch:

applyEditsToNormalizedContent(): tìm, kiểm tra trùng và chồng lấn, rồi áp
pi/packages/coding-agent/src/core/tools/edit-diff.tsdòng 316–331, 341–359TypeScript
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;const matchedEdits: MatchedEdit[] = [];for (let i = 0; i < normalizedEdits.length; i++) {	const edit = normalizedEdits[i];	const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);	if (!matchResult.found) {		throw getNotFoundError(path, i, normalizedEdits.length);	}	const occurrences = countOccurrences(replacementBaseContent, edit.oldText);	if (occurrences > 1) {		throw getDuplicateError(path, i, normalizedEdits.length, occurrences);	}matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);for (let i = 1; i < matchedEdits.length; i++) {	const previous = matchedEdits[i - 1];	const current = matchedEdits[i];	if (previous.matchIndex + previous.matchLength > current.matchIndex) {		throw new Error(			`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,		);	}}const baseContent = normalizedContent;const newContent = usedFuzzyMatch	? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)	: applyReplacements(replacementBaseContent, matchedEdits);if (baseContent === newContent) {	throw getNoChangeError(path, normalizedEdits.length);}

Quy tắc cụ thể:

  • Chính xác trước. fuzzyFindText() thử indexOf nguyên văn; chỉ khi không thấy mới chuẩn hoá cả hai phía rồi tìm lại (edit-diff.ts:207–245).
  • Một edit mờ thì cả lời gọi mờ. Nếu bất kỳ edit nào cần khớp mờ, mọi edit được tìm lại trên nội dung đã chuẩn hoá (dòng 318), để tất cả vị trí nằm trong cùng một hệ toạ độ.
  • Phải duy nhất. countOccurrences() luôn đếm trên nội dung đã chuẩn hoá (edit-diff.ts:247–251), kể cả khi khớp chính xác.
  • Không chồng lấn (dòng 345–348), và phải thay đổi được gì đó (dòng 357–358).

Chuẩn hoá mờ làm năm việc: NFKC, bỏ khoảng trắng cuối mỗi dòng, đổi dấu nháy cong thành nháy thẳng, đổi các loại gạch ngang Unicode thành -, đổi các khoảng trắng đặc biệt thành dấu cách (edit-diff.ts:34–55). Tính năng có từ 0.46.0 (CHANGELOG.md:4392). Khi khớp mờ, Pi không ghi cả file ở dạng đã chuẩn hoá: applyReplacementsPreservingUnchangedLines() chỉ viết lại những dòng bị edit chạm vào, mọi dòng khác được chép lại nguyên byte từ bản gốc (edit-diff.ts:122–136).

Các thông báo lỗi được viết để mô hình đọc và tự sửa: “Could not find the exact text in … The old text must match exactly including all whitespace and newlines.”, “Found 2 occurrences … Please provide more context to make it unique.”, hay “edits[0] and edits[1] overlap … Merge them into one edit” (edit-diff.ts:253–289).

Diff cho giao diện#

Sau khi ghi, edit tạo hai biểu diễn của thay đổi, cả hai nằm trong details. generateDiffString() cho diff hiển thị: số dòng, bốn dòng ngữ cảnh mỗi phía, dấu ... thay cho đoạn dài không đổi, cùng firstChangedLine để editor nhảy tới (edit-diff.ts:372). generateUnifiedPatch() cho một patch unified chuẩn, dựng bằng thư viện diff (edit-diff.ts:365–370). Mô hình chỉ nhận một câu: Successfully replaced N block(s) in <path>.

write và hàng đợi ghi theo file#

write là tool đơn giản nhất: tạo thư mục cha nếu cần rồi ghi đè cả file.

write.execute(): mkdir -p rồi ghi, cũng trong hàng đợi
pi/packages/coding-agent/src/core/tools/write.tsdòng 65–90TypeScript
	const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);	const dir = dirname(absolutePath);	return withFileMutationQueue(absolutePath, async () => {		// Do not reject from an abort event listener here: that would release the		// mutation queue while an in-flight filesystem operation may still finish.		// Checking signal.aborted after each await observes the same aborts while		// keeping the queue locked until the current operation has settled.		const throwIfAborted = (): void => {			if (signal?.aborted) throw new Error("Operation aborted");		};		throwIfAborted();		// Create parent directories if needed.		await ops.mkdir(dir);		throwIfAborted();		// Write the file contents.		await ops.writeFile(absolutePath, content);		throwIfAborted();		return {			content: [{ type: "text", text: `Successfully wrote to ${path}` }],			details: undefined,		};	});},

Guideline của nó, “Use write only for new files or complete rewrites.”, đẩy mô hình sang edit cho mọi thay đổi cục bộ: với write, mô hình phải sinh lại toàn bộ nội dung file trong đối số content.

Vì sao cần hàng đợi#

Agent chạy batch tool song song theo mặc định (agent.ts:261), và không tool có sẵn nào đặt executionMode: "sequential". Mô hình hoàn toàn có thể phát hai lời gọi edit vào cùng một file trong một câu trả lời. Không có khoá, cả hai cùng đọc bản gốc, mỗi bên áp edit của mình, và bên ghi sau xoá mất thay đổi của bên ghi trước. withFileMutationQueue() tuần tự hoá thao tác trên cùng một file và vẫn để các file khác nhau chạy song song:

Ba chi tiết làm hàng đợi đúng:

  • Khoá là đường dẫn thật (realpath, dòng 19): ./a.ts, src/../a.ts và một symlink trỏ tới a.ts rơi vào cùng một hàng. File chưa tồn tại thì dùng đường dẫn đã resolve.
  • Đăng ký cũng xếp hàng. realpath là bất đồng bộ, nên nếu mỗi lời gọi tự tính khoá, lời gọi đến sau có thể đăng ký trước. registrationQueue buộc các lần đăng ký chạy lần lượt, nên thứ tự trong hàng đúng bằng thứ tự gọi. Vòng lặp khởi động các tool song song theo đúng thứ tự trong câu trả lời của mô hình (agent-loop.ts:656–658), nên edit đứng trước trong câu trả lời được áp trước.
  • Dọn dẹp. Xong việc thì nhả khoá cho người kế tiếp, và xoá mục trong map nếu không còn ai xếp sau (dòng 56–59), để map không lớn dần.

Ba tool này tắt theo mặc định. Chúng dành cho những cấu hình không có bash, như một agent chỉ được đọc, hoặc khi cần output có giới hạn và định dạng ổn định. Khi bash bật mà không tool nào trong ba tool này bật, system prompt thêm luật “Use bash for file operations like ls, rg, find” (system-prompt.ts:108–116).

grep chạy ripgrep. Nếu máy chưa có rg, ensureTool() tải bản phù hợp về ~/.pi/agent/bin, trừ khi đang ở chế độ offline (tools-manager.ts:350–380). Các cờ được cố định:

grep: cờ ripgrep cố định, không đọc cấu hình của người dùng
pi/packages/coding-agent/src/core/tools/grep.tsdòng 162–168TypeScript
// --no-config: the tool contract (literal, glob, limit, ...) must not depend on// ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions.const args: string[] = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"];if (ignoreCase) args.push("--ignore-case");if (literal) args.push("--fixed-strings");if (glob) args.push("--glob", glob);args.push("--", pattern, searchPath);

--no-config để hợp đồng của tool (literal, glob, limit...) không phụ thuộc RIPGREP_CONFIG_PATH của người dùng; --hidden tìm cả dotfile nhưng vẫn tôn trọng .gitignore. Pi đọc các sự kiện match từ output --json, giết rg khi đủ 100 kết quả (mặc định), tự đọc file để lấy dòng ngữ cảnh khi có context, cắt mỗi dòng còn 500 ký tự, rồi cắt cả output theo 50KB. Mỗi giới hạn bị chạm sinh ra một thông báo hành động, ví dụ “100 matches limit reached. Use limit=200 for more, or refine pattern” (grep.ts:282–305).

find chạy fd theo cùng cách:

find: fd với glob, .gitignore và mẫu có dấu gạch chéo
pi/packages/coding-agent/src/core/tools/find.tsdòng 182–214TypeScript
const args: string[] = ["--glob", "--color=never", "--hidden"];// fd normally ignores .gitignore outside git repos, so keep --no-require-git// there. Inside repos, use fd's default git-aware behavior so parent// .gitignore rules stop at nested repo boundaries:// https://github.com/earendil-works/pi/issues/5960let insideGitRepo = false;for (let current = searchPath; ; ) {	if (await pathExists(path.join(current, ".git"))) {		insideGitRepo = true;		break;	}	const parent = path.dirname(current);	if (parent === current) break;	current = parent;}if (!insideGitRepo) args.push("--no-require-git");args.push("--max-results", String(effectiveLimit));// fd --glob matches against the basename unless --full-path is set; in --full-path// mode it matches against the absolute candidate path, so a path-containing// pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything.let effectivePattern = pattern;if (pattern.includes("/")) {	args.push("--full-path");	if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") {		effectivePattern = `**/${pattern}`;	}	// fd matches full paths using native separators on Windows.	if (process.platform === "win32")		effectivePattern = effectivePattern.replaceAll("/", String.raw`[/\\]`);}args.push("--", effectivePattern, searchPath);

Hai chỗ đáng để ý. Ngoài một git repo, fd mặc định bỏ qua .gitignore, nên Pi thêm --no-require-git; trong repo thì dùng hành vi mặc định của fd để luật .gitignore của repo cha dừng ở ranh giới repo con. Và fd --glob chỉ so với tên file, nên một mẫu chứa / như src/**/*.spec.ts được chạy với --full-path và tiền tố **/. Mặc định tối đa 1.000 kết quả.

ls không gọi chương trình ngoài: đọc thư mục, sắp xếp không phân biệt hoa thường, thêm / sau thư mục, gồm cả dotfile, tối đa 500 mục (ls.ts:108–130). Như mọi tool có sẵn, cả ba nhận một interface *Operations để chạy trên máy khác; find còn cho thay hẳn fd bằng hàm glob() riêng.

Renderer: phần giao diện thấy#

Mỗi tool có sẵn trải renderCall và renderResult từ tools/renderers/ vào definition của mình (ví dụ edit.ts:214). Phần vẽ được tách khỏi phần chạy để một tiến trình chỉ hiển thị không phải nạp code thực thi và schema TypeBox, khoảng 17MB module (renderers/index.ts:1–7); withBuiltInRenderers() lắp renderer có sẵn vào definition nào chưa tự có (renderers/index.ts:51–63).

Một lời gọi tool có mặt trên màn hình từ trước khi chạy. Trong lúc assistant message còn stream, mỗi message_update có khối toolCall mới sẽ tạo một ToolExecutionComponent và cập nhật đối số của nó khi JSON dài dần (interactive-mode.ts:3486–3510). Sau đó tool_execution_start đánh dấu bắt đầu chạy, mỗi tool_execution_update vẽ lại với kết quả tạm, và tool_execution_end vẽ kết quả cuối cùng, gồm cả details:

Chế độ interactive: kết quả tạm và kết quả cuối vào cùng một component
pi/packages/coding-agent/src/modes/interactive/interactive-mode.tsdòng 3590–3608TypeScript
case "tool_execution_update": {	const component = this.pendingTools.get(event.toolCallId);	if (component) {		component.updateResult({ ...event.partialResult, isError: false }, true);		this.ui.requestRender();	}	break;}case "tool_execution_end": {	if (event.isError) this.maybeShowInstallChangeWarning();	const component = this.pendingTools.get(event.toolCallId);	if (component) {		component.updateResult({ ...event.result, isError: event.isError, durationMs: event.durationMs });		this.pendingTools.delete(event.toolCallId);		this.ui.requestRender();	}	break;}

Hai renderer đáng xem. Renderer của bash chỉ hiện năm dòng cuối, có phím mở rộng, và thay dòng thông báo “Full output: …” trong text bằng một dòng cảnh báo dựng từ details.truncation (renderers/bash.ts:39–87). Renderer của edit không chờ tool chạy xong: ngay khi đối số stream xong, nó tự tính diff xem trước trên file hiện tại, rồi thay bằng diff thật trong details khi kết quả về:

Renderer của edit: diff xem trước trước cả khi tool chạy
pi/packages/coding-agent/src/core/tools/renderers/edit.tsdòng 186–195TypeScript
if (context.argsComplete && previewInput && !component.preview && !component.previewPending) {	component.previewPending = true;	const requestKey = argsKey;	void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => {		if (component.previewArgsKey === requestKey) {			setEditPreview(component, preview, requestKey);			context.invalidate();		}	});}
computeEditsDiff() chạy cùng thuật toán khớp với tool nhưng không ghi gì (edit-diff.ts:510–543), nên người dùng thấy diff, hay lỗi “không tìm thấy”, ngay khi mô hình vừa viết xong lời gọi.

Lệnh ! của người dùng#

Người dùng gõ !git status trong editor thì lệnh không đi qua mô hình. AgentSession.executeBash() chạy nó bằng chính BashOperations mà tool bash dùng (nên extension nào thay backend cho tool cũng thay được cho lệnh !), qua một bộ thực thi riêng, bash-executor.ts (agent-session.ts:3901–3933). Khác biệt chính so với tool là output được làm sạch cho người đọc:

executeBashWithOperations(): bỏ mã ANSI và ký tự nhị phân, giữ bộ đệm cuốn
pi/packages/coding-agent/src/core/bash-executor.tsdòng 76–104TypeScript
const appendText = (rawText: string) => {	// Sanitize: strip ANSI, replace binary garbage, normalize newlines	const text = sanitizeBinaryOutput(stripAnsi(rawText)).replace(/\r/g, "");	if (!text) {		return;	}	// Start writing to temp file if exceeds threshold	if (totalBytes > DEFAULT_MAX_BYTES) {		ensureTempFile();	}	if (tempFileStream) {		tempFileStream.write(text);	}	// Keep rolling buffer	outputChunks.push(text);	outputBytes += text.length;	while (outputBytes > maxOutputBytes && outputChunks.length > 1) {		const removed = outputChunks.shift()!;		outputBytes -= removed.length;	}	// Stream to callback	if (options?.onChunk) {		options.onChunk(text);	}};

Kết quả thành một BashExecutionMessage ghi vào session (agent-session.ts:3939–3960). Ở request kế tiếp, convertToLlm() đổi nó thành một tin nhắn user dạng “Ran `git status`” kèm output trong khối code (messages.ts:82–98). Lệnh !! chạy y hệt nhưng bị loại khỏi context (messages.ts:152–156). Thời điểm ghi, và vì sao message bị hoãn khi agent đang chạy, thuộc về chương AgentSession.

System prompt được dựng thế nào#

Toàn bộ việc dựng prompt nằm trong system-prompt.ts, 229 dòng, gần như toàn hàm thuần (chỉ đường dẫn tài liệu là lấy từ nơi cài đặt): đầu vào là một đối tượng tuỳ chọn, đầu ra là các section văn bản. Đầu vào trông như sau:

BuildSystemPromptOptions: mọi thứ prompt cần biết
pi/packages/coding-agent/src/core/system-prompt.tsdòng 9–37TypeScript
export interface BuildSystemPromptOptions {	/** Custom system prompt (replaces the default prefix). */	customPrompt?: string;	/** Exact full prompt replacement set by a before_agent_start handler. */	forceSystemPrompt?: string;	/** Tools to include in prompt. Default: [read, bash, edit, write]. */	selectedTools?: string[];	/**	 * Selected tools whose declarations requests leave out (`prepareLoadout` hidden declarations).	 * They are reachable only through another tool, so the tool list and rules leave them out too.	 */	hiddenTools?: string[];	/** Optional one-line tool snippets keyed by tool name. */	toolSnippets?: Record<string, string>;	/** Guideline bullets contributed by each tool, keyed by tool name. */	toolGuidelines?: Record<string, string[]>;	/** Additional guideline bullets appended to the default system prompt rules. */	promptGuidelines?: string[];	/** Text appended from user configuration before project context, skills, and cwd. */	appendSystemPrompt?: string;	/** Additional XML-wrapped prompt sections keyed by tag name. */	sections?: Record<string, string>;	/** Working directory. */	cwd: string;	/** Pre-loaded context files. */	contextFiles?: Array<{ path: string; content: string }>;	/** Pre-loaded skills. */	skills?: Skill[];}
AgentSession dựng đối tượng này trong _rebuildSystemPrompt(), xem bên dưới. Extension đọc và sửa được nó trong sự kiện before_agent_start (chương Extensions).

Prompt không còn là một chuỗi dài mà là các section có tên. preamble là câu mở đầu không bọc thẻ; mọi section khác được bọc trong một thẻ cùng tên:

buildSystemPromptSections(): thứ tự và nội dung các section
pi/packages/coding-agent/src/core/system-prompt.tsdòng 150–161, 172–192TypeScript
const declaredTools = selectedTools.filter((name) => !hiddenTools.includes(name));const promptSections: Record<string, string> = {};if (customPrompt) {	promptSections.preamble = customPrompt;} else {	promptSections.preamble =		"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";	const visibleTools = declaredTools.filter((name) => !!toolSnippets[name]);	const tools =		visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)";	promptSections.tools = `${tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.`;	promptSections.rules = buildRules(declaredTools, toolGuidelines, promptGuidelines);if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt;if (contextFiles.length > 0) promptSections.project_context = renderProjectContext(contextFiles);// A hidden reader is still reachable through another tool, so skills stay but the hint names no tool.const readers = ["read", "bash"] as const;const skillFileReadTool =	readers.find((tool) => declaredTools.includes(tool)) ??	(readers.some((tool) => selectedTools.includes(tool)) ? "indirect" : undefined);if (skillFileReadTool && skills.length > 0) {	const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim();	if (skillsPrompt) promptSections.skills = skillsPrompt;}promptSections.cwd = cwd.replace(/\\/g, "/");for (const [name, content] of Object.entries(customSections)) {	if (content) promptSections[name] = content;}const sections: SystemPromptSections = { preamble: promptSections.preamble };for (const [name, content] of Object.entries(promptSections)) {	if (name !== "preamble") sections[name] = `<${name}>\n${content}\n</${name}>`;}return sections;

Thứ tự cố định: preamble, tools, rules, docs, addendum, project_context, skills, cwd, rồi các section tuỳ biến. Vài điểm đáng chú ý:

  • Mục tools chỉ liệt kê tool có promptSnippet (dòng 157), kèm một câu nhắc rằng có thể còn tool khác. Mô hình vẫn thấy đủ tool qua phần khai báo tool của request; mục này chỉ là bản tóm tắt.
  • customPrompt thay cả bốn section đầu (dòng 152–153). Với --system-prompt hay SYSTEM.md, mất luôn danh sách tool, luật và mục tài liệu, nhưng vẫn giữ addendum, AGENTS.md, skills và thư mục làm việc.
  • Mục docs chỉ đường tới README, docs/ và examples/ của gói Pi đã cài, và dặn chỉ đọc khi người dùng hỏi về chính Pi (system-prompt.ts:162–169). Nhờ mục này, khi được nhờ viết extension cho chính Pi, mô hình biết tài liệu nằm đâu để đọc; Armin Ronacher coi đây là một trong những điểm mạnh của Pi (Pi: The Minimal Agent Within OpenClaw).
  • Không có ngày giờ. Mục cuối cùng là thư mục làm việc, đổi dấu \ thành / (dòng 183). Ngày hiện tại đã bị bỏ khỏi prompt từ 0.80.7 vì nó làm prompt cache trượt mỗi khi qua ngày mới (CHANGELOG.md:1444).

Mục rules được ghép từ guidelines của các tool đang khai báo, theo thứ tự tool, bỏ trùng, rồi thêm hai luật chung:

buildRules(): luật phụ thuộc tool nào đang bật
pi/packages/coding-agent/src/core/system-prompt.tsdòng 88–125TypeScript
function buildRules(	selectedTools: string[],	toolGuidelines: Record<string, string[]>,	promptGuidelines: string[],): string {	const rules: string[] = [];	const seen = new Set<string>();	const addRule = (rule: string): void => {		const normalized = rule.trim();		if (!normalized || seen.has(normalized)) return;		seen.add(normalized);		rules.push(normalized);	};	const hasBash = selectedTools.includes("bash");	const hasPowerShell = selectedTools.includes("powershell");	const hasGrep = selectedTools.includes("grep");	const hasFind = selectedTools.includes("find");	const hasLs = selectedTools.includes("ls");	if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) {		if (hasBash && hasPowerShell) {			addRule("Use bash or PowerShell for file operations like listing, searching, and finding files");		} else if (hasPowerShell) {			addRule("Use PowerShell for file operations like listing, searching, and finding files");		} else {			addRule("Use bash for file operations like ls, rg, find");		}	}	for (const name of selectedTools) {		for (const rule of toolGuidelines[name] ?? []) addRule(rule);	}	for (const rule of promptGuidelines) addRule(rule);	addRule("Be concise in your responses");	addRule("Show file paths clearly when working with files");	return rules.map((rule) => `- ${rule}`).join("\n");}
Luật ở dòng 108–116 là luật duy nhất nhìn vào tổ hợp tool: chỉ khi có shell mà không có grep/find/ls mới bảo mô hình dùng shell để tìm file. Changelog 0.77.0 ghi lại lý do: prompt cũ khuyên dùng các tool tìm file kể cả khi chúng không bật (CHANGELOG.md:1998).

Prompt nằm trong transcript#

Các section không chỉ được nối thành chuỗi. Chúng trở thành SystemMessage.sections, một message nằm ngay trong transcript:

SystemMessage của pi-ai: nội dung, section có tên, tool thêm và bớt
pi/packages/ai/src/types.tsdòng 554–570TypeScript
export interface SystemMessage {	role: "system";	/** Instruction text. On the leading message this is the base prompt; later, additional instructions. */	content: string | TextContent[];	/**	 * Named, ordered prompt sections rendered verbatim after `content`. The leading message	 * declares them; later messages replace sections by name, and `null` removes one. Keep	 * each section self-delimiting (a tag, a heading) so the model can relate an update to	 * the original. Avoid integer-like names; JSON objects reorder those.	 */	sections?: Record<string, string | null>;	/** Complete definitions of tools that become available at this point. */	toolsAdded?: Tool[];	/** Tools that stop being available at this point. */	toolsRemoved?: ToolReference[];	timestamp: number; // Unix timestamp in milliseconds}

Message system đầu tiên khai báo các section; các message system về sau chỉ thay section theo tên, null là xoá. getSystemMessageText() ghép content với các section bằng một dòng trống (pi-ai utils/text.ts:15–21). Khi prompt cần đổi giữa chừng (một tool vừa bật, một skill mới được nạp, AGENTS.md đổi sau /reload), AgentSession không viết lại message đầu tiên mà tính một bản vá:

diffSystemPromptSections(): chỉ gửi những section đã đổi
pi/packages/coding-agent/src/core/system-prompt.tsdòng 217–229TypeScript
export function diffSystemPromptSections(	previous: Record<string, string | null>,	current: SystemPromptSections,): Record<string, string | null> | undefined {	const patch: Record<string, string | null> = {};	for (const [name, text] of Object.entries(current)) {		if (previous[name] !== text) patch[name] = text;	}	for (const name of Object.keys(previous)) {		if (current[name] === undefined) patch[name] = null;	}	return Object.keys(patch).length > 0 ? patch : undefined;}

Bản vá được nối vào cuối transcript dưới dạng một message system mới (agent-session.ts:1758–1770). Lý do là prompt cache (chương Compaction có phần nói kỹ): cache khớp theo tiền tố chính xác, nên sửa message đầu tiên làm trượt cache của toàn bộ hội thoại, còn nối thêm ở cuối thì giữ nguyên tiền tố. Với mô hình hỗ trợ message system giữa hội thoại, provider nhận bản vá có tên section (utils/text.ts:28–40). Tính năng có từ 0.86.0 (CHANGELOG.md:609); cách nó đi qua session và resume thuộc chương AgentSession và chương Pi 1.0.

Còn một đường thứ ba: extension trả về systemPrompt trong before_agent_start sẽ đặt forceSystemPrompt, một chuỗi đóng kín thay toàn bộ prompt, không có section (system-prompt.ts:199–205).

Ai gom đầu vào#

_rebuildSystemPrompt(): tool, SYSTEM.md, APPEND_SYSTEM.md, skills và AGENTS.md
pi/packages/coding-agent/src/core/agent-session.tsdòng 1720–1746TypeScript
private _rebuildSystemPrompt(toolNames: string[]): void {	const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));	const toolSnippets: Record<string, string> = {};	for (const name of this._toolRegistry.keys()) {		const snippet = this._toolPromptSnippets.get(name);		// Tools without a snippet are not listed.		if (snippet) toolSnippets[name] = snippet;	}	const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();	const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();	const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : "";	const loadedSkills = this._resourceLoader.getSkills().skills;	const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;	this._baseSystemPromptOptions = normalizeBuildSystemPromptOptions({		cwd: this._cwd,		skills: loadedSkills,		contextFiles: loadedContextFiles,		customPrompt: loaderSystemPrompt,		appendSystemPrompt,		selectedTools: validToolNames,		hiddenTools: [...this._hiddenDeclarations],		toolSnippets,		toolGuidelines: Object.fromEntries(this._toolPromptGuidelines),	});}

Snippet và guideline lấy từ registry tool (đã gom ở _refreshToolRegistry()); mọi thứ khác lấy từ ResourceLoader, chủ đề của mục kế tiếp. Hàm chạy lại mỗi khi bộ tool đổi hay tài nguyên được nạp lại.

AGENTS.md, SYSTEM.md và skills#

Context file#

Context file là file hướng dẫn của dự án mà Pi chèn vào prompt. Mỗi thư mục đóng góp tối đa một file, theo thứ tự ưu tiên cố định:

Mỗi thư mục một context file: AGENTS.override.md thắng, CLAUDE.md đứng cuối
pi/packages/coding-agent/src/core/resource-loader.tsdòng 184–203TypeScript
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {	const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];	for (const filename of candidates) {		const filePath = join(dir, filename);		if (existsSync(filePath)) {			try {				if (!statSync(filePath).isFile()) {					continue;				}				return {					path: filePath,					content: stripBom(readFileSync(filePath, "utf-8")),				};			} catch (error) {				console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));			}		}	}	return null;}

Thư mục nào có cả AGENTS.md lẫn CLAUDE.md thì chỉ AGENTS.md được nạp. Các thư mục được duyệt như sau:

loadProjectContextFiles(): file toàn cục trước, rồi từ gốc ổ đĩa xuống cwd
pi/packages/coding-agent/src/core/resource-loader.tsdòng 239–246, 251–273TypeScript
const contextFiles: Array<{ path: string; content: string }> = [];const seenPaths = new Set<string>();const globalContext = loadContextFileFromDir(resolvedAgentDir);if (globalContext) {	contextFiles.push(globalContext);	seenPaths.add(globalContext.path);}let currentDir = resolvedCwd;while (true) {	const contextFile = loadContextFileFromDir(currentDir);	// Compare the directory's real path, not the file's: a worktree AGENTS.md that	// symlinks to the main repo's file resolves to the shadowed path itself.	const isShadowed =		shadowedContextFile !== undefined &&		contextFile !== null &&		join(canonicalizePath(currentDir), basename(contextFile.path)) === shadowedContextFile;	if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {		ancestorContextFiles.unshift(contextFile);		seenPaths.add(contextFile.path);	}	const parentDir = dirname(currentDir);	if (parentDir === currentDir) break;	currentDir = parentDir;}contextFiles.push(...ancestorContextFiles);return contextFiles;

File toàn cục trong ~/.pi/agent/ đứng đầu. Sau đó vòng lặp đi từ thư mục làm việc lên tới gốc hệ thống file; unshift (dòng 262) đảo thứ tự, nên trong prompt file của thư mục xa nhất đứng trước, file gần nhất đứng sau cùng. Đường dẫn đã thấy thì bỏ qua. Riêng trường hợp một git worktree nằm lồng bên trong repo chính, bản AGENTS.md của repo chính bị bỏ để không áp cùng một hướng dẫn hai lần (resource-loader.ts:205–230). Mỗi file được bọc trong <project_instructions path="..."> (system-prompt.ts:79–86). --no-context-files (-nc) tắt toàn bộ việc này (resource-loader.ts:646–653).

SYSTEM.md, APPEND_SYSTEM.md và cờ dòng lệnh#

--system-prompt <văn bản> trở thành customPrompt, thay bốn section đầu như đã nói ở trên; --append-system-prompt thêm vào section addendum và dùng được nhiều lần (cli/args.ts:121–125). Giá trị của cả hai có thể là đường dẫn: nếu file tồn tại thì Pi đọc nội dung file, không thì dùng nguyên chuỗi (resource-loader.ts:167–182). Không có cờ thì Pi tìm file:

SYSTEM.md và APPEND_SYSTEM.md: bản của dự án cần trust, bản toàn cục thì không
pi/packages/coding-agent/src/core/resource-loader.tsdòng 1213–1239TypeScript
private discoverSystemPromptFile(): string | undefined {	const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");	if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {		return projectPath;	}	const globalPath = join(this.agentDir, "SYSTEM.md");	if (existsSync(globalPath)) {		return globalPath;	}	return undefined;}private discoverAppendSystemPromptFile(): string | undefined {	const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");	if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {		return projectPath;	}	const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");	if (existsSync(globalPath)) {		return globalPath;	}	return undefined;}

Khác với AGENTS.md, .pi/SYSTEM.md và .pi/APPEND_SYSTEM.md của dự án chỉ được dùng khi dự án đã được trust; bản trong ~/.pi/agent/ luôn được dùng. Cờ dòng lệnh thắng cả hai (resource-loader.ts:657–676).

Skills: chỉ tên và mô tả#

Skill là một file SKILL.md chứa hướng dẫn cho một loại việc (cách tìm và nạp skill thuộc chương Extensions). Prompt không chứa nội dung skill, chỉ chứa danh mục:

formatSkillsForPrompt(): tên, mô tả, vị trí; nội dung nạp khi cần
pi/packages/coding-agent/src/core/skills.tsdòng 358–388TypeScript
export function formatSkillsForPrompt(skills: Skill[], fileReadTool: "read" | "bash" | "indirect" = "read"): string {	const visibleSkills = skills.filter((s) => !s.disableModelInvocation);	if (visibleSkills.length === 0) {		return "";	}	const lines = [		"\n\nThe following skills provide specialized instructions for specific tasks.",		fileReadTool === "read"			? "Use the read tool to load a skill's file when the task matches its description."			: fileReadTool === "bash"				? "Use bash to load a skill's file when the task matches its description."				: "Load a skill's file when the task matches its description.",		"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",		"",		"<available_skills>",	];	for (const skill of visibleSkills) {		lines.push("  <skill>");		lines.push(`    <name>${escapeXml(skill.name)}</name>`);		lines.push(`    <description>${escapeXml(skill.description)}</description>`);		lines.push(`    <location>${escapeXml(skill.filePath)}</location>`);		lines.push("  </skill>");	}	lines.push("</available_skills>");	return lines.join("\n");}
Skill đặt disableModelInvocation không vào danh mục (dòng 359); người dùng vẫn gọi được nó bằng lệnh.

Mô hình thấy tên, mô tả và đường dẫn của từng skill; khi việc đang làm khớp với mô tả, nó tự đọc SKILL.md bằng read (hoặc bash). Danh mục chỉ xuất hiện khi một trong hai tool đọc đó tồn tại (system-prompt.ts:174–182). Đây là cùng một kiểu nạp lười như bên tool search: chi phí prompt của một skill là vài chục token cho mô tả, còn hướng dẫn dài vài nghìn token chỉ tốn khi thật sự dùng.

Ví dụ minh hoạ: khung của system prompt mặc định, với một AGENTS.md và một skill
[preamble: một câu nói mô hình là trợ lý lập trình trong harness pi]

<tools>
- read: [promptSnippet của read]
- bash: [promptSnippet của bash]
- edit: [promptSnippet của edit]
- write: [promptSnippet của write]

[câu nhắc: có thể còn tool tuỳ biến khác]
</tools>

<rules>
- [luật dùng bash để tìm file, vì grep/find/ls đang tắt]
- [guidelines của read, bash, edit, write]
- [hai luật chung: ngắn gọn, ghi rõ đường dẫn]
</rules>

<docs>
[đường dẫn README, docs/, examples/ của gói pi đã cài]
</docs>

<project_context>
[một câu dẫn]

<project_instructions path="/home/me/work/app/AGENTS.md">
[nội dung AGENTS.md]
</project_instructions>
</project_context>

<skills>
[cách nạp skill]
<available_skills>
  <skill>
    <name>release</name>
    <description>Cut a release of this repo</description>
    <location>/home/me/work/app/.pi/skills/release/SKILL.md</location>
  </skill>
</available_skills>
</skills>

<cwd>
/home/me/work/app
</cwd>
Văn bản thật của từng phần nằm trong các đoạn code ở trên; khung này chỉ cho thấy thứ tự và thẻ.

“Dưới 1.000 token”: điều gì còn đúng ở v1.4.0#

Bài blog tháng 11/2025 của Mario Zechner viết: “pi's system prompt and tool definitions together come in below 1000 tokens” (What I learned building an opinionated and minimal coding agent). Có thể đo lại ở v1.4.0 bằng chính các chuỗi trong source. Cách đo: ghép prompt mặc định (bốn tool, không AGENTS.md, không skill, cwd là /home/user/project, gói Pi cài ở một đường dẫn toàn cục dài 59 ký tự), tuần tự hoá khai báo bốn tool thành JSON kiểu Anthropic (name, description, input_schema), rồi đổi ký tự ra token bằng heuristic của chính Pi, số ký tự chia 4 (compaction.ts:294–298).

PhầnKý tự≈ token (ký tự/4)
preamble16943
<tools>33985
<rules>839210
<docs>1.319330
<cwd>318
System prompt2.705677
Khai báo read655164
Khai báo bash514129
Khai báo edit1.150288
Khai báo write401101
Khai báo tool2.725682
Tổng5.430≈ 1.360

Con số là ước lượng: tokenizer thật của mỗi provider đếm khác, và Pi tự gọi heuristic này là thận trọng, tức có xu hướng đếm dư. Nhưng kết luận đủ rõ: ở v1.4.0 prompt cùng tool mặc định không còn dưới 1.000 token, mà vào khoảng 1.200 đến 1.400. Điều vẫn đúng là bậc độ lớn: khoảng một nghìn token, không phải hàng chục nghìn. Phần tăng đến từ những thay đổi có lý do, đều có trong changelog:

  • edit nhận nhiều edit (0.63.0, 0.63.2). Khai báo của nó giờ lớn nhất trong bốn tool, cộng thêm bốn guideline trong <rules>.
  • Mục <docs> thành bản đồ tài liệu: liệt kê tài liệu theo chủ đề (extension, theme, skill, SDK, MCP, codemode...) để mô hình tự đọc khi được hỏi về Pi. Riêng mục này chiếm khoảng một phần tư tổng số.
  • Thẻ XML thay cho tiêu đề Markdown (CHANGELOG.md:2156, bản 0.75.0), để mô hình phân biệt ranh giới các phần rõ hơn.
  • Một số thứ bị bỏ để giữ cache: giờ hiện tại bị bỏ ở 0.58.0 (CHANGELOG.md:3316), ngày hiện tại bị bỏ ở 0.80.7. Prompt nhỏ đi một chút, và quan trọng hơn là không còn đổi theo thời gian.

Những gì giữ nguyên: bốn tool mặc định, grep/find/ls vẫn tắt, phần mở đầu vẫn là một câu, và AGENTS.md vẫn được nối vào sau prompt gốc. Trong một dự án thật, AGENTS.md và danh mục skill thường lớn hơn cả prompt gốc lẫn bốn khai báo tool cộng lại.

Trace: mô hình gọi edit#

Người dùng nhờ: “cho server đọc cổng từ biến PORT”. Mô hình đã read file src/server.ts ở lượt trước, giờ trả lời bằng một lời gọi tool:

Ví dụ minh hoạ: khối toolCall trong assistant message
{
  "type": "toolCall",
  "id": "toolu_01",
  "name": "edit",
  "arguments": {
    "path": "src/server.ts",
    "edits": [
      { "oldText": "const port = 3000;",
        "newText": "const port = Number(process.env.PORT ?? 3000);" }
    ]
  }
}
  1. Stream đối sốpi-ai ghép JSON của arguments từ từng mảnh khi stream (chương pi-ai). Giao diện tạo component cho lời gọi ngay từ mảnh đầu; khi đối số đủ, renderer của edit tính diff xem trước trên file thật. Chưa có gì được ghi.
  2. Vào batchAssistant message kết thúc. Vòng lặp lấy mọi khối toolCall, chọn chế độ song song (không tool nào đòi tuần tự) và phát tool_execution_start (agent-loop.ts:606–614).
  3. Chuẩn bịTìm tool theo tên, chạy prepareArguments, kiểm tra schema, gọi hook beforeToolCall. Mỗi bước hỏng đều thành một kết quả lỗi “ngay lập tức” mà tool không hề chạy (đoạn code bên dưới).
  4. Thực thiexecutePreparedToolCall() gọi AgentTool.execute(); lớp bọc thêm ctx rồi gọi ToolDefinition.execute(). Thời gian chạy đo bằng đồng hồ đơn điệu; exception bị bắt và thành kết quả lỗi (agent-loop.ts:831–867).
  5. Bên trong editĐăng ký vào hàng đợi của src/server.ts, kiểm tra quyền, đọc, tách BOM, chuẩn hoá LF. indexOf("const port = 3000;") thấy đúng một chỗ, đếm trùng ra 1, không chồng lấn. Áp edit, khôi phục kiểu xuống dòng, ghi file, tạo diff và patch.
  6. Sau khi chạyafterToolCall phát sự kiện tool_result cho extension, bên đó có thể thay content, details hay isError (agent-loop.ts:880–907; agent-session.ts:684).
  7. Báo kết quảtool_execution_end mang toàn bộ AgentToolResult; giao diện thay diff xem trước bằng details.diff thật. Rồi createToolResultMessage() dựng ToolResultMessage, phát message_start/message_end.
  8. Ghi sessionAgentSession nhận message_end và ghi message thành một SessionMessageEntry, giữ nguyên details (agent-session.ts:1158–1166).
  9. Request kế tiếpContext được dựng lại từ session; adapter của provider biến message thành khối tool_result chỉ có content và is_error. Mô hình đọc đúng một câu: “Successfully replaced 1 block(s) in src/server.ts.”

Bước 3 là nơi phần lớn lỗi dừng lại:

prepareToolCall(): không thấy tool, sai schema, bị chặn đều thành kết quả lỗi
pi/packages/agent/src/agent-loop.tsdòng 726–737, 755–765, 780–786TypeScript
const tool = tools.find((t) => t.name === toolCall.name);if (!tool) {	return {		kind: "immediate",		result: createErrorToolResult(`Tool ${toolCall.name} not found`),		isError: true,	};}try {	const preparedToolCall = prepareToolCallArguments(tool, toolCall);	const validatedArgs = validateToolArguments(tool, preparedToolCall);		if (beforeResult?.block) {			const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");			if (beforeResult.terminate === true) {				result.terminate = true;			}			return {				kind: "immediate",				result,				isError: true,			};		}} catch (error) {	return {		kind: "immediate",		result: createErrorToolResult(error instanceof Error ? error.message : String(error)),		isError: true,	};}
Dòng 755 là chỗ một lời gọi bị extension chặn biến thành kết quả lỗi mang lý do chặn; cách extension chặn thuộc chương Extensions.

Lỗi schema do validateToolArguments() của pi-ai tạo. Hàm sao chép đối số, ép kiểu theo schema (chuỗi "5" thành số 5 cho timeout), kiểm tra, và nếu sai thì liệt kê từng lỗi kèm nguyên văn đối số đã nhận, để mô hình thấy chính xác mình gửi gì (pi-ai validation.ts:317–353).

Giả sử mô hình viết oldText là port thay vì cả dòng. Bước 5 khi đó throw: “Found 3 occurrences of the text in src/server.ts. The text must be unique. Please provide more context to make it unique.” Bước 4 bắt exception và trả một kết quả lỗi với đúng câu đó; còn giao diện thì đã hiện lỗi này trong khung lời gọi ngay từ bước 1, nhờ diff xem trước. Mô hình đọc lỗi và gọi lại với oldText dài hơn. Không có code “thử lại” nào trong harness: vòng lặp chỉ cần trả lỗi về đúng dạng.

AiNhận gì từ lời gọi này
Mô hìnhMột dòng text trong content và cờ lỗi
Giao diệnĐối số đang stream, diff xem trước, details.diff, isError, durationMs
File sessionToolResultMessage đầy đủ: content, details (diff, patch, firstChangedLine), isError, durationMs
Extensiontool_call với đối số đã kiểm tra; tool_result với content, details, isError

Quyết định thiết kế#

Bốn tool là đủ#

Bài blog năm 2025 nói thẳng: “these four tools are all you need for an effective coding agent”, vì “models know how to use bash and have been trained on the read, write, and edit tools with similar input schemas” (Mario Zechner). Mọi việc khác (tìm kiếm, chạy test, git, xem process) đi qua bash với những chương trình mô hình đã quen. Mỗi tool thêm vào có hai cái giá: vài trăm token khai báo trong mọi request, và một chỗ có thể làm trượt prompt cache, vì khai báo tool nằm gần đầu prompt nên đổi bộ tool là đổi tiền tố (Prompt Caching In Agents). grep, find, ls vẫn có cho cấu hình không có shell, nhưng tắt theo mặc định.

Cũng bài đó có câu “Bash is all you need”, viết khi giải thích vì sao Pi không có bash chạy nền: muốn một tiến trình sống lâu thì dùng tmux. Ở v1.4.0 vẫn vậy: tool bash không có chế độ nền, không có timeout mặc định, và mỗi lời gọi chờ tiến trình kết thúc.

Không có hệ thống quyền#

Pi không hỏi xác nhận trước mỗi lời gọi tool. Tài liệu bảo mật mở đầu bằng câu: Pi “can read, change, and execute files with the permissions of the account that started it, and it does not ask for approval before every tool call” (docs/security.md:3). Lý do là hộp thoại xác nhận không tạo ra ranh giới an toàn: an toàn đến từ việc giới hạn file, credential, tiến trình và mạng mà Pi chạm được, còn việc xem transcript hay review thay đổi thì không phải ranh giới (docs/security.md:7). Bài blog nói gắt hơn: các biện pháp bảo mật của agent khác “mostly security theater”, và “as soon as your agent can write code and run code, it's pretty much game over”.

Thay cho hộp thoại, tài liệu đưa ra các mức cô lập: chạy cả Pi trong container, Docker Sandboxes hay OpenShell, hoặc giữ Pi trên máy và chỉ đẩy tool vào một micro-VM Gondolin (docs/containerization.md:9–14). Phương án cuối dùng đúng các interface *Operations ở trên: extension Gondolin thay read, write, edit, bash, grep, find, ls bằng bản chạy trong VM (docs/containerization.md:181). Ai vẫn muốn hỏi xác nhận thì viết extension bắt sự kiện tool_call; repo có sẵn ví dụ permission-gate.ts (docs/extensions.md:390). Bộ --tools read,grep,find,ls thu hẹp khả năng, không phải sandbox: read vẫn đọc được mọi file mà tài khoản của bạn đọc được.

Kết quả hai phần, thông báo hành động#

Tách content khỏi details là quyết định đã có từ thời bài blog 2025 (“splitting tool results into a portion handed to the LLM and a portion for UI display”). Nó cho phép edit trả mô hình một câu trong khi người dùng xem diff đầy đủ, và cho bash báo thông tin cắt bớt cho giao diện mà không tốn token. Cùng tinh thần đó, mọi giới hạn đều đi kèm một câu nói bước tiếp theo (offset=…, limit=…, “Full output: …”): harness không tóm tắt hay đoán hộ, nó để mô hình tự phân trang bằng chính các tool sẵn có.

Backend thay được#

Mỗi tool nhận một interface *Operations với comment “Override these to delegate … to remote systems (for example SSH)” (ví dụ read.ts:44–55). Logic của tool (cắt bớt, khớp văn bản, thông báo lỗi) giữ nguyên; chỉ chỗ chạm vào hệ thống file và tiến trình được thay. Đây là cách SSH, container hay micro-VM được cắm vào mà không phải viết lại tool.

Cạm bẫy và trường hợp biên#

  • Lệnh không bao giờ tự dừng. bash không có timeout mặc định, nên npm run dev giữ lượt hiện tại cho tới khi người dùng huỷ. Chạy nền bằng & cũng chưa chắc thoát được: theo comment của waitForChildProcess(), một tiến trình con vẫn đang ghi vào pipe kế thừa sẽ giữ Pi tiếp tục đọc. Hãy chuyển output của tiến trình nền ra file (cmd > log 2>&1 &) hoặc dùng tmux.
  • Huỷ là SIGKILL. Hết giờ hay bị huỷ thì cả nhóm tiến trình nhận SIGKILL, không có cơ hội dọn dẹp. Tiến trình nào tự tách khỏi nhóm (daemon dùng setsid) thì thoát khỏi lệnh giết này.
  • Đường dẫn không bị giới hạn. Đường dẫn tuyệt đối, ~ và .. đều dùng được; thư mục làm việc chỉ là gốc cho đường dẫn tương đối (docs/security.md:19). Bộ tool chỉ đọc vẫn đọc được ~/.ssh.
  • bash không vào hàng đợi. Hàng đợi ghi chỉ bọc edit và write. Một lời gọi bash chạy sed -i song song với một edit trên cùng file vẫn có thể giẫm lên nhau.
  • Khớp mờ đổi cả dòng. Khi phải khớp mờ, các dòng bị edit chạm vào được viết lại ở dạng đã chuẩn hoá: mất khoảng trắng cuối dòng, nháy cong thành nháy thẳng. Ngược lại, đếm trùng luôn chạy trên dạng chuẩn hoá, nên một đoạn duy nhất khi so nguyên văn vẫn có thể bị báo trùng nếu sau chuẩn hoá nó giống một đoạn khác.
  • Mô hình chỉ thấy đuôi. Với một build log dài, lỗi đầu tiên thường nằm ở đầu, mà bash chỉ giữ 2.000 dòng cuối. Mô hình phải tự read file đầy đủ theo đường dẫn trong thông báo; file nằm trong thư mục tạm của hệ điều hành.
  • Thứ bạn thấy khác thứ mô hình thấy. Renderer bỏ mã màu ANSI và ký tự nhị phân trước khi vẽ (render-utils.ts:67), còn content gửi cho mô hình là output thô. Lệnh ép màu (FORCE_COLOR=1, --color=always) sẽ đổ escape code vào context dù màn hình trông sạch. Lệnh ! của người dùng thì được làm sạch trước khi vào context.
  • --system-prompt bỏ nhiều hơn bạn nghĩ. Prompt tuỳ biến thay cả mục tool, luật và tài liệu. Khai báo tool vẫn được gửi, nhưng các guideline như “edits[].oldText must match exactly” thì mất, trừ khi bạn tự viết lại.
  • Ảnh và mô hình không đọc ảnh. Với mô hình không nhận ảnh, read vẫn trả khối ảnh nhưng ghi chú rằng ảnh sẽ bị bỏ khỏi request; ảnh bị thu nhỏ thì toạ độ mô hình nói ra là toạ độ trên ảnh nhỏ, phải nhân với hệ số trong ghi chú.
  • Context file của repo lạ. AGENTS.md và CLAUDE.md nạp không cần trust, như đã nói ở trên. Đó là một đường prompt injection có sẵn.

Đọc code ở đâu#

Đường dẫn tính từ packages/coding-agent/, trừ khi ghi khác. Thứ tự gợi ý: từ định nghĩa, qua các tool, tới system prompt.

FileĐọc để hiểu
src/core/extensions/types.tsToolDefinition, ToolRenderContext: hợp đồng của mọi tool
packages/agent/src/types.tsAgentToolResult, AgentTool: thứ vòng lặp gọi và nhận về
packages/coding-agent/src/core/tools/index.ts, packages/coding-agent/src/core/tools/tool-definition-wrapper.tsTám tên tool, các bộ dựng sẵn, cách bọc definition
packages/coding-agent/src/core/tools/truncate.tsGiới hạn chung, cắt đầu và cắt đuôi
packages/coding-agent/src/core/tools/read.ts, packages/coding-agent/src/core/tools/path-utils.tsĐọc text và ảnh, phân giải đường dẫn
packages/coding-agent/src/core/tools/bash.ts, packages/coding-agent/src/core/tools/output-accumulator.ts, packages/coding-agent/src/utils/shell.tsSinh tiến trình, timeout, giết cây tiến trình, luồng output, file tạm
packages/coding-agent/src/core/tools/edit.ts, packages/coding-agent/src/core/tools/edit-diff.tsThay thế đúng văn bản, khớp mờ, diff
packages/coding-agent/src/core/tools/file-mutation-queue.ts, packages/coding-agent/src/core/tools/write.tsKhoá ghi theo file thật
packages/coding-agent/src/core/tools/grep.ts, packages/coding-agent/src/core/tools/find.ts, packages/coding-agent/src/core/tools/ls.tsBộ chỉ đọc qua ripgrep và fd
packages/coding-agent/src/core/tools/renderers/index.tsRenderer tách khỏi phần thực thi
packages/agent/src/agent-loop.ts (dòng 518–959)Một lời gọi tool đi từ chuẩn bị tới ToolResultMessage
packages/coding-agent/src/core/bash-executor.tsLệnh ! của người dùng
packages/coding-agent/src/core/system-prompt.tsCác section của prompt, luật, bản vá giữa hội thoại
src/core/resource-loader.ts (dòng 167–274, 646–676, 1213–1239)AGENTS.md, SYSTEM.md, APPEND_SYSTEM.md
src/core/agent-session.ts (dòng 1720–1770, 3555–3718)Registry tool và nơi gom đầu vào cho prompt
packages/coding-agent/docs/security.md, packages/coding-agent/docs/containerization.mdVì sao không có hệ thống quyền, và nên cô lập thế nào

Tóm tắt#

  • Tám tool có sẵn, bốn tool bật mặc định: read, bash, edit, write. Mọi việc khác đi qua bash.
  • Mỗi tool là một ToolDefinition có ba mặt: khai báo cho mô hình, đóng góp cho system prompt, renderer cho giao diện. Kết quả tách content (cho mô hình) khỏi details (cho giao diện và session).
  • Output bị cắt ở 2.000 dòng hoặc 50KB, kèm câu hướng dẫn lấy tiếp; bash giữ đuôi và lưu toàn bộ ra file tạm.
  • edit thay đúng văn bản: khớp nguyên văn trước, khớp mờ sau, bắt buộc duy nhất và không chồng lấn, giữ BOM và CRLF. Hàng đợi theo đường dẫn thật ngăn các lần ghi song song đè nhau.
  • System prompt là các section có tên, nằm trong transcript; đổi giữa chừng thì nối bản vá chứ không viết lại, để giữ prompt cache. Không còn ngày giờ trong prompt.
  • AGENTS.md/CLAUDE.md nạp từ thư mục toàn cục và mọi thư mục cha, không cần trust; skills chỉ góp tên, mô tả và vị trí.
  • Prompt và bốn khai báo tool mặc định giờ khoảng 1.300 token (ước lượng ký tự/4), không còn dưới 1.000 như năm 2025.
  • Không có hệ thống quyền: an toàn đến từ cô lập ở tầng hệ điều hành; muốn hỏi xác nhận thì viết extension.

Mục lục

Giao diện