From 92cca23e663020c2c60089ee44b3101a3aab7aab Mon Sep 17 00:00:00 2001 From: Ahmed Hindy Date: Mon, 20 Jul 2026 21:57:05 +0300 Subject: [PATCH] Load nested workspace instructions lazily --- src/server.ts | 109 ++++++++++++++++++++++--------- src/workspaces.test.ts | 21 +++++- src/workspaces.ts | 144 ++++++++++++++++++++++++----------------- 3 files changed, 180 insertions(+), 94 deletions(-) diff --git a/src/server.ts b/src/server.ts index 7dd9629e..3e07721f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,7 +45,7 @@ import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; -import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { formatAgentsPath, type LoadedAgentsFile, WorkspaceRegistry } from "./workspaces.js"; import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; import { formatLocalAgentProviderAvailabilitySummary, @@ -196,7 +196,7 @@ function serverInstructions(config: ServerConfig): string { ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` : ""; - const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; + const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. DevSpace returns newly encountered AGENTS.md or CLAUDE.md files when a later path-aware tool enters their directory; follow those instructions before continuing. `; return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChangesInstruction}`; } @@ -344,6 +344,15 @@ function textBlock(text: string): ToolContent { return { type: "text", text }; } +function instructionContent( + agentsFiles: LoadedAgentsFile[], + workspaceRoot: string, +): ToolContent[] { + return agentsFiles.map((file) => textBlock( + `Loaded project instructions from ${formatAgentsPath(file.path, workspaceRoot)}:\n${file.content}`, + )); +} + function textSummary(content: ToolContent[]): { lines: number; characters: number; @@ -512,9 +521,10 @@ function processToolResponse( workspaceId: string, snapshot: ProcessSnapshot, summary: Record, + additionalContent: ToolContent[] = [], ) { const result = processResult(snapshot); - const content = [textBlock(result)]; + const content = [textBlock(result), ...additionalContent]; const outputSummary = textSummary(snapshot.output ? [textBlock(snapshot.output)] : []); return { content, @@ -527,7 +537,7 @@ function processToolResponse( }, }, structuredContent: { - result, + result: contentText(content), sessionId: snapshot.sessionId, running: snapshot.running, exitCode: snapshot.exitCode, @@ -587,6 +597,11 @@ function registerCodexProcessTools( const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory); + const agentsFiles = await workspaces.loadAgentsFilesForPath( + workspace, + cwd, + "directory", + ); const snapshot = await processSessions.start({ workspaceId, command: cmd, @@ -615,7 +630,7 @@ function registerCodexProcessTools( running: snapshot.running, exitCode: snapshot.exitCode, wallTimeMs: snapshot.wallTimeMs, - }); + }, instructionContent(agentsFiles, workspace.root)); }, ); @@ -739,7 +754,7 @@ function createMcpServer( { title: "Open workspace", description: - "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder; do not call open_workspace again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session. Returns a workspaceId, loaded root project instructions, and nested instruction file paths the model should read before working in those directories.", + "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder; do not call open_workspace again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session. Returns a workspaceId and root project instructions. DevSpace returns nested instructions when a later tool enters their directory.", inputSchema: { path: z .string() @@ -817,8 +832,8 @@ function createMcpServer( path: formatAgentsPath(file.path, workspace.root), })); const instruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions and any nested instructions returned by later path-aware tool calls. When a task matches an available skill in skills, read its path before proceeding." + : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions and any nested instructions returned by later path-aware tool calls."; const resultContent: ToolContent[] = [ { type: "text" as const, @@ -940,6 +955,9 @@ function createMcpServer( const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); const readPath = workspaces.resolveReadPath(workspace, input.path); + const agentsFiles = readPath.skillRead + ? [] + : await workspaces.loadAgentsFilesForPath(workspace, readPath.absolutePath); const response = await readFileTool( { ...input, path: readPath.absolutePath }, { @@ -958,9 +976,10 @@ function createMcpServer( return response; } workspaces.markReadPathLoaded(workspace, readPath); + const content = [...response.content, ...instructionContent(agentsFiles, workspace.root)]; const summary = { - ...textSummary(response.content), + ...textSummary(content), offset: input.offset ?? 1, limited: input.limit !== undefined, }; @@ -974,17 +993,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.read, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1014,7 +1034,8 @@ function createMcpServer( async ({ workspaceId, ...input }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); + const path = workspaces.resolvePath(workspace, input.path); + const agentsFiles = await workspaces.loadAgentsFilesForPath(workspace, path); const response = await writeFileTool(input, { cwd: workspace.root, root: workspace.root, @@ -1030,6 +1051,7 @@ function createMcpServer( } const patch = newFilePatch(input.path, input.content); + const content = [...response.content, ...instructionContent(agentsFiles, workspace.root)]; const stats = countDiffStats(patch); const summary = { ...stats, @@ -1046,6 +1068,7 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.write, card: { @@ -1053,13 +1076,13 @@ function createMcpServer( path: input.path, summary, payload: { - content: response.content, + content, patch, }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1101,7 +1124,8 @@ function createMcpServer( async ({ workspaceId, ...input }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); + const path = workspaces.resolvePath(workspace, input.path); + const agentsFiles = await workspaces.loadAgentsFilesForPath(workspace, path); const response = await editFileTool(input, { cwd: workspace.root, root: workspace.root, @@ -1124,7 +1148,10 @@ function createMcpServer( editCount: input.edits.length, }; const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; - const editContent = [textBlock(editResultText)]; + const editContent = [ + textBlock(editResultText), + ...instructionContent(agentsFiles, workspace.root), + ]; logToolCall(config, { tool: toolNames.edit, workspaceId, @@ -1315,7 +1342,10 @@ function createMcpServer( async ({ workspaceId, ...input }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); - if (input.path) workspaces.resolvePath(workspace, input.path); + const path = input.path + ? workspaces.resolvePath(workspace, input.path) + : workspace.root; + const agentsFiles = await workspaces.loadAgentsFilesForPath(workspace, path); const response = await grepFilesTool(input, { cwd: workspace.root, root: workspace.root, @@ -1330,10 +1360,11 @@ function createMcpServer( return response; } + const content = [...response.content, ...instructionContent(agentsFiles, workspace.root)]; const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...textSummary(content), }; logToolCall(config, { tool: toolNames.grep, @@ -1345,17 +1376,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.grep, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1385,7 +1417,10 @@ function createMcpServer( async ({ workspaceId, ...input }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); - if (input.path) workspaces.resolvePath(workspace, input.path); + const path = input.path + ? workspaces.resolvePath(workspace, input.path) + : workspace.root; + const agentsFiles = await workspaces.loadAgentsFilesForPath(workspace, path); const response = await findFilesTool(input, { cwd: workspace.root, root: workspace.root, @@ -1400,10 +1435,11 @@ function createMcpServer( return response; } + const content = [...response.content, ...instructionContent(agentsFiles, workspace.root)]; const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...textSummary(content), }; logToolCall(config, { tool: toolNames.glob, @@ -1415,17 +1451,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.glob, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1455,7 +1492,8 @@ function createMcpServer( async ({ workspaceId, ...input }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); + const path = workspaces.resolvePath(workspace, input.path); + const agentsFiles = await workspaces.loadAgentsFilesForPath(workspace, path); const response = await listDirectoryTool(input, { cwd: workspace.root, root: workspace.root, @@ -1470,7 +1508,8 @@ function createMcpServer( return response; } - const summary = textSummary(response.content); + const content = [...response.content, ...instructionContent(agentsFiles, workspace.root)]; + const summary = textSummary(content); logToolCall(config, { tool: toolNames.ls, workspaceId, @@ -1481,17 +1520,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.ls, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1540,6 +1580,11 @@ function createMcpServer( workspace, workingDirectory, ); + const agentsFiles = await workspaces.loadAgentsFilesForPath( + workspace, + cwd, + "directory", + ); const response = await runShellTool(input, { cwd, root: workspace.root, @@ -1556,10 +1601,11 @@ function createMcpServer( return response; } + const content = [...response.content, ...instructionContent(agentsFiles, workspace.root)]; const summary = { command: input.command, workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), + ...textSummary(content), }; logToolCall(config, { tool: toolNames.shell, @@ -1573,17 +1619,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.shell, card: { workspaceId, path: workingDirectory, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4f3eb769..52a1c5d9 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -60,8 +60,17 @@ try { ["global instructions\n", "root instructions\n"], ); assert.deepEqual( - availableAgentsFiles.map((file) => file.path), - [join(root, "nested", "AGENTS.md")], + availableAgentsFiles, + [], + ); + assert.deepEqual( + (await registry.loadAgentsFilesForPath(workspace, join(root, "nested", "file.txt"))) + .map((file) => file.content), + ["nested instructions\n"], + ); + assert.deepEqual( + await registry.loadAgentsFilesForPath(workspace, join(root, "nested", "file.txt")), + [], ); assert.deepEqual( workspace.agentProfiles.map((profile) => ({ @@ -98,6 +107,14 @@ try { unsafeWorkspace.agentsFiles.map((file) => file.content), ["root instructions\n"], ); + + const unsafeNestedDir = join(root, "unsafe-nested"); + await mkdir(unsafeNestedDir); + await symlink(join(outsideRoot, "secret.txt"), join(unsafeNestedDir, "AGENTS.md")); + assert.deepEqual( + await registry.loadAgentsFilesForPath(workspace, join(unsafeNestedDir, "file.txt")), + [], + ); } const missingWorkspaceRoot = join(root, "missing", "workspace"); diff --git a/src/workspaces.ts b/src/workspaces.ts index e3c252f4..cd4d0d96 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Stats } from "node:fs"; import type { WorkspaceMode, WorkspaceStore } from "./workspace-store.js"; -import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises"; +import { mkdir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; @@ -47,6 +47,8 @@ export interface Workspace { skillDiagnostics: LoadedSkills["diagnostics"]; agentProfiles: LocalAgentProfile[]; activatedSkillDirs: Set; + loadedAgentsPaths: Set; + loadedAgentsRealPaths: Set; } export interface WorkspaceContext { @@ -124,6 +126,8 @@ export class WorkspaceRegistry { ...this.loadSkillsForWorkspace(root), agentProfiles: [], activatedSkillDirs: new Set(), + loadedAgentsPaths: new Set(), + loadedAgentsRealPaths: new Set(), }; this.store?.touchSession(workspaceId); this.workspaces.set(restoredWorkspace.id, restoredWorkspace); @@ -173,6 +177,71 @@ export class WorkspaceRegistry { return assertAllowedPath(directory, [workspace.root]); } + /** + * Loads instruction files that apply to a path and have not already been returned. + * + * @param workspace The active workspace. + * @param absolutePath An absolute, workspace-contained path. + * @param pathType Whether the path itself is a directory or a file path. + * @returns Newly discovered instruction files ordered from workspace root to target. + */ + async loadAgentsFilesForPath( + workspace: Workspace, + absolutePath: string, + pathType: "auto" | "directory" | "file" = "auto", + ): Promise { + const root = resolve(workspace.root); + const resolvedRoot = (await tryRealpath(root)) ?? root; + const resolvedPath = resolve(absolutePath); + let directory = pathType === "directory" ? resolvedPath : dirname(resolvedPath); + if (pathType === "auto") { + try { + directory = (await stat(resolvedPath)).isDirectory() + ? resolvedPath + : dirname(resolvedPath); + } catch { + directory = dirname(resolvedPath); + } + } + const directories: string[] = []; + + while (isPathInsideRoot(directory, root)) { + directories.push(directory); + if (directory === root) break; + + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + + const discovered: LoadedAgentsFile[] = []; + for (const contextDirectory of directories.reverse()) { + for (const fileName of CONTEXT_FILE_NAMES) { + const path = join(contextDirectory, fileName); + const resolvedPath = resolve(path); + if (workspace.loadedAgentsPaths.has(resolvedPath)) continue; + + const realPath = await tryRealpath(path); + if (!realPath || !isPathInsideRoot(realPath, resolvedRoot)) continue; + if (workspace.loadedAgentsRealPaths.has(realPath)) { + workspace.loadedAgentsPaths.add(resolvedPath); + continue; + } + + try { + const content = await readFile(realPath, "utf8"); + const file = { path: resolvedPath, content }; + await this.rememberAgentsFiles(workspace, [file], [realPath]); + discovered.push(file); + } catch { + // A context file may disappear or become unreadable between realpath and readFile. + } + } + } + + return discovered; + } + private async openCheckoutWorkspace(path: string): Promise { const root = assertAllowedPath(path, this.config.allowedRoots); const rootStats = await ensureCheckoutWorkspaceRoot(root); @@ -213,6 +282,8 @@ export class WorkspaceRegistry { ...this.loadSkillsForWorkspace(input.root), agentProfiles: await loadLocalAgentProfiles(this.config, input.root), activatedSkillDirs: new Set(), + loadedAgentsPaths: new Set(), + loadedAgentsRealPaths: new Set(), }; this.store?.createSession({ @@ -226,9 +297,9 @@ export class WorkspaceRegistry { }); this.workspaces.set(workspace.id, workspace); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); - const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); + await this.rememberAgentsFiles(workspace, agentsFiles); - return { workspace, agentsFiles, availableAgentsFiles }; + return { workspace, agentsFiles, availableAgentsFiles: [] }; } private loadSkillsForWorkspace(root: string): Pick { @@ -277,29 +348,16 @@ export class WorkspaceRegistry { return loadedFiles; } - private async findAvailableAgentsFiles( - root: string, - loadedFiles: LoadedAgentsFile[], - ): Promise { - const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path))); - const loadedRealPaths = new Set(); - for (const file of loadedFiles) { - const realPath = await tryRealpath(file.path); - if (realPath) loadedRealPaths.add(realPath); + private async rememberAgentsFiles( + workspace: Workspace, + files: LoadedAgentsFile[], + realPaths?: string[], + ): Promise { + for (const [index, file] of files.entries()) { + workspace.loadedAgentsPaths.add(resolve(file.path)); + const realPath = realPaths?.[index] ?? await tryRealpath(file.path); + if (realPath) workspace.loadedAgentsRealPaths.add(realPath); } - const discovered: AvailableAgentsFile[] = []; - - await walkWorkspace(root, async (path, entry) => { - if (!entry.isFile()) return; - if (!CONTEXT_FILE_NAMES.has(entry.name)) return; - if (loadedPaths.has(path)) return; - const realPath = await tryRealpath(path); - if (realPath && loadedRealPaths.has(realPath)) return; - - discovered.push({ path }); - }); - - return discovered.sort((a, b) => a.path.localeCompare(b.path)); } } @@ -320,18 +378,6 @@ export async function ensureCheckoutWorkspaceRoot( } const CONTEXT_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); -const SKIPPED_CONTEXT_DIRS = new Set([ - ".git", - ".hg", - ".svn", - ".devspace", - "node_modules", - "dist", - "build", - ".next", - ".turbo", - ".cache", -]); export function formatAgentsPath(path: string, workspaceRoot: string | undefined): string { if (!workspaceRoot) return path.split(sep).join("/"); @@ -377,30 +423,6 @@ async function tryRealpath(path: string): Promise { } } -async function walkWorkspace( - directory: string, - visit: (path: string, entry: { name: string; isFile(): boolean; isDirectory(): boolean }) => Promise | void, -): Promise { - let entries; - try { - entries = await opendir(directory); - } catch { - return; - } - - for await (const entry of entries) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - if (!SKIPPED_CONTEXT_DIRS.has(entry.name)) { - await walkWorkspace(path, visit); - } - continue; - } - - await visit(path, entry); - } -} - function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; }