Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,32 @@ test("open_workspace reports aggregate review availability", async (t) => {
assert.deepEqual(gitReview, { available: true });
});

test("nested instructions are returned once when read enters their directory", async (t) => {
const context = await fixture(t);
const workspaceId = structuredContent(
await callOpen(context.client, context.project, "nested-instructions"),
).workspaceId;
assert.equal(typeof workspaceId, "string");

const firstRead = await context.client.callTool({
name: "read",
arguments: { workspaceId, path: "nested/file.txt" },
});
assert.match(
structuredContent(firstRead).result as string,
/Loaded project instructions from nested\/AGENTS.md:\nnested instructions/,
);

const secondRead = await context.client.callTool({
name: "read",
arguments: { workspaceId, path: "nested/file.txt" },
});
assert.doesNotMatch(
structuredContent(secondRead).result as string,
/nested instructions/,
);
});

test("show_changes keeps model output compact and preserves the rich review card", async (t) => {
const context = await fixture(t, { git: true, uiEnabled: false });
const opened = structuredContent(
Expand Down Expand Up @@ -409,6 +435,9 @@ async function fixture(
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n");
await writeFile(join(project, "AGENTS.md"), "project instructions\n");
await mkdir(join(project, "nested"));
await writeFile(join(project, "nested", "AGENTS.md"), "nested instructions\n");
await writeFile(join(project, "nested", "file.txt"), "nested file\n");
await writeFile(join(project, ".devspace", "agents", "reviewer.md"), [
"---",
"name: reviewer",
Expand Down
24 changes: 15 additions & 9 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
import { getToolSurface } from "./tool-surfaces/index.js";
import {
contentText,
instructionContent,
logFailedToolResponse,
logToolCall,
resultOutputSchema,
Expand Down Expand Up @@ -106,7 +107,7 @@ function serverInstructions(
const skills = config.skillsEnabled
? `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 agents = `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 agents = `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. `;
const common = `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected.`;

return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`;
Expand Down Expand Up @@ -436,10 +437,12 @@ export function createMcpServer(
const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : [];
const visibleAgents = includeBootstrapContext ? cardAgents : [];
const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
const availableAgentsFileOutputs = includeBootstrapContext
? cardAvailableAgentsFiles
: [];
const cardInstruction = config.skillsEnabled
? "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. 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 for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file.";
? "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. DevSpace returns newly encountered AGENTS.md or CLAUDE.md files when a later path-aware tool enters their directory. When a task matches an available skill in skills, read its path before proceeding."
: "Use this workspaceId for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agentsFiles instructions. DevSpace returns newly encountered AGENTS.md or CLAUDE.md files when a later path-aware tool enters their directory.";
const instruction = workspaceReused
? [
`Workspace already open as ${workspace.id}.`,
Expand All @@ -463,9 +466,6 @@ export function createMcpServer(
loadedAgentsFiles.length > 0
? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}`
: undefined,
availableAgentsFileOutputs.length > 0
? `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}`
: undefined,
visibleSkills.length > 0
? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
: undefined,
Expand Down Expand Up @@ -546,7 +546,7 @@ export function createMcpServer(
description:
[
"Read a file in a workspace. Use this for file inspection instead of shell commands like cat or sed.",
"Use this tool to inspect relevant AGENTS.md or CLAUDE.md files listed by open_workspace before working in nested directories.",
"When this enters a directory with unreturned AGENTS.md or CLAUDE.md files, those instructions are returned with the file content; follow them before continuing.",
config.skillsEnabled
? "If available skills were returned and a task matches one, read that skill's path before proceeding. Skill paths may be outside the workspace; only advertised SKILL.md files and files under already-loaded skill directories are readable."
: "",
Expand Down Expand Up @@ -584,6 +584,11 @@ export function createMcpServer(
const startedAt = performance.now();
const workspace = workspaces.getWorkspace(workspaceId);
const readPath = workspaces.resolveReadPath(workspace, input.path);
const agentsFiles = await workspaces.loadAgentsFilesForPath(
workspace,
readPath.absolutePath,
"file",
);
const response = await readFileTool(
{ ...input, path: readPath.absolutePath },
{
Expand Down Expand Up @@ -613,8 +618,9 @@ export function createMcpServer(

return {
...response,
content: [...response.content, ...instructionContent(agentsFiles, workspace.root)],
structuredContent: {
result: contentText(response.content),
result: contentText([...response.content, ...instructionContent(agentsFiles, workspace.root)]),
},
};
},
Expand Down
31 changes: 26 additions & 5 deletions src/tool-surfaces/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
contentText,
countDiffStats,
instructionContent,
logFailedToolResponse,
logToolCall,
resultOutputSchema,
Expand Down Expand Up @@ -59,7 +60,12 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void {
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,
"file",
);
const response = await writeFileTool(input, {
cwd: workspace.root,
root: workspace.root,
Expand Down Expand Up @@ -89,8 +95,9 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void {

return {
...response,
content: [...response.content, ...instructionContent(agentsFiles, workspace.root)],
structuredContent: {
result: contentText(response.content),
result: contentText([...response.content, ...instructionContent(agentsFiles, workspace.root)]),
},
};
},
Expand Down Expand Up @@ -127,7 +134,12 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void {
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,
"file",
);
const response = await editFileTool(input, {
cwd: workspace.root,
root: workspace.root,
Expand All @@ -151,7 +163,10 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void {
response.details?.patch ?? response.details?.diff,
);
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,
Expand Down Expand Up @@ -209,6 +224,11 @@ function registerShellTool(context: ToolRegistrationContext): void {
workspace,
workingDirectory,
);
const agentsFiles = await workspaces.loadAgentsFilesForPath(
workspace,
cwd,
"directory",
);
const response = await runShellTool(input, {
cwd,
root: workspace.root,
Expand Down Expand Up @@ -242,8 +262,9 @@ function registerShellTool(context: ToolRegistrationContext): void {

return {
...response,
content: [...response.content, ...instructionContent(agentsFiles, workspace.root)],
structuredContent: {
result: contentText(response.content),
result: contentText([...response.content, ...instructionContent(agentsFiles, workspace.root)]),
},
};
},
Expand Down
54 changes: 36 additions & 18 deletions src/tool-surfaces/codex.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import * as z from "zod/v4";
import { applyPatch } from "../apply-patch.js";
import type { ProcessSnapshot } from "../process-sessions.js";
import type { LoadedAgentsFile } from "../workspaces.js";
import {
EDIT_TOOL_ANNOTATIONS,
SHELL_TOOL_ANNOTATIONS,
toolNames,
workspaceIdDescription,
type ToolContent,
type ToolRegistrationContext,
} from "./types.js";
import {
contentText,
instructionContent,
resultOutputSchema,
runLoggedToolOperation,
textBlock,
Expand Down Expand Up @@ -56,13 +59,16 @@ function processOutputSchema(): z.ZodRawShape {
});
}

function processToolResponse(snapshot: ProcessSnapshot) {
function processToolResponse(
snapshot: ProcessSnapshot,
additionalContent: ToolContent[] = [],
) {
const result = processResult(snapshot);
const content = [textBlock(result)];
const content = [textBlock(result), ...additionalContent];
return {
content,
structuredContent: {
result,
result: contentText(content),
sessionId: snapshot.sessionId,
running: snapshot.running,
exitCode: snapshot.exitCode,
Expand Down Expand Up @@ -105,18 +111,27 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void {
},
async ({ workspaceId, patch }) => {
const startedAt = performance.now();
const workspace = workspaces.getWorkspace(workspaceId);
const applied = await runLoggedToolOperation(
config,
{ tool: "apply_patch", workspaceId },
startedAt,
async () => {
const workspace = workspaces.getWorkspace(workspaceId);
return applyPatch(workspace.root, patch);
},
async () => applyPatch(workspace.root, patch),
);
const agentsFiles: LoadedAgentsFile[] = [];
for (const file of applied.files) {
agentsFiles.push(...await workspaces.loadAgentsFilesForPath(
workspace,
workspaces.resolvePath(workspace, file.path),
"file",
));
}
const paths = applied.files.map((file) => file.path).join(", ");
const result = `Applied patch to ${applied.files.length} file(s): ${paths}`;
const content = [textBlock(result)];
const content = [
textBlock(result),
...instructionContent(agentsFiles, workspace.root),
];

return {
content,
Expand Down Expand Up @@ -200,6 +215,13 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void {
maxOutputTokens,
}) => {
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 runLoggedToolOperation(
config,
{
Expand All @@ -210,13 +232,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void {
commandLength: cmd.length,
},
startedAt,
async () => {
const workspace = workspaces.getWorkspace(workspaceId);
const cwd = workspaces.resolveWorkingDirectory(
workspace,
workingDirectory,
);
return processSessions.start({
async () => processSessions.start({
workspaceId,
command: cmd,
cwd,
Expand All @@ -226,11 +242,13 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void {
rows,
yieldTimeMs,
maxOutputTokens,
});
},
}),
);

return processToolResponse(snapshot);
return processToolResponse(
snapshot,
instructionContent(agentsFiles, workspace.root),
);
},
);

Expand Down
10 changes: 10 additions & 0 deletions src/tool-surfaces/shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as z from "zod/v4";
import { logEvent, commandPreview } from "../logger.js";
import type { ServerConfig } from "../config.js";
import { formatAgentsPath, type LoadedAgentsFile } from "../workspaces.js";
import {
WORKSPACE_APP_URI,
type DiffStats,
Expand Down Expand Up @@ -104,6 +105,15 @@ export function textBlock(text: string): ToolContent {
return { type: "text", text };
}

export function instructionContent(
agentsFiles: LoadedAgentsFile[],
workspaceRoot: string,
): ToolContent[] {
return agentsFiles.map((file) => textBlock(
`Loaded project instructions from ${formatAgentsPath(file.path, workspaceRoot)}:\n${file.content}`,
));
}

export function countDiffStats(diff: string | undefined): DiffStats {
if (!diff) return { additions: 0, removals: 0 };

Expand Down
34 changes: 31 additions & 3 deletions src/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { writeTestDevspaceConfig } from "./test-support/config.test.js";

const execFileAsync = promisify(execFile);

test("a checkout exposes initial and nested instruction context while filtering outside symlinks", async (t) => {
test("a checkout loads nested instruction context lazily while filtering outside symlinks", async (t) => {
const context = await fixture(t);
const opened = await context.registry.openWorkspace(context.root);

Expand All @@ -24,8 +24,22 @@ test("a checkout exposes initial and nested instruction context while filtering
["global instructions\n", "root instructions\n"],
);
assert.deepEqual(
opened.availableAgentsFiles.map((file) => file.path),
[join(context.root, "nested", "AGENTS.md")],
opened.availableAgentsFiles,
[],
);
assert.deepEqual(
(await context.registry.loadAgentsFilesForPath(
opened.workspace,
join(context.root, "nested", "file.txt"),
)).map((file) => file.content),
["nested instructions\n"],
);
assert.deepEqual(
await context.registry.loadAgentsFilesForPath(
opened.workspace,
join(context.root, "nested", "file.txt"),
),
[],
);
assert.deepEqual(
opened.workspace.agentProfiles.map((profile) => ({
Expand Down Expand Up @@ -65,6 +79,20 @@ test("a checkout exposes initial and nested instruction context while filtering
unsafeWorkspace.agentsFiles.map((file) => file.content),
["root instructions\n"],
);

const unsafeNestedDir = join(context.root, "unsafe-nested");
await mkdir(unsafeNestedDir);
await symlink(
join(context.outsideRoot, "secret.txt"),
join(unsafeNestedDir, "AGENTS.md"),
);
assert.deepEqual(
await context.registry.loadAgentsFilesForPath(
opened.workspace,
join(unsafeNestedDir, "file.txt"),
),
[],
);
}
});

Expand Down
Loading
Loading