Skip to content

Commit f0de68e

Browse files
committed
🤖 fix: present sub-projects to the agent as regular projects
Sub-project workspaces now look indistinguishable from a regular single-project workspace rooted at the sub-project directory: - The <environment> block uses the same per-runtime description and guardrails it would use for any project at that cwd. No "the X sub-project of the Y at Z" framing, no relative-paths preamble. - Bash tool description is unchanged from the regular single-project case (same "Runs in <cwd> - no cd needed" form). - AGENTS.md is concatenated parent → sub-project (parent first so general rules anchor before specific overrides) but with no segment headings — the agent perceives a single AGENTS.md, exactly like a regular project's. The parent root is derived by stripping the recorded sub-project relative segment off the cwd, so worktree/SSH /Docker workspaces read the parent's AGENTS.md from their own branch's checkout (not from the user's local checkout, which may be on a different commit). - Stale-metadata fallback: if the recorded subProjectPath isn't a descendant of projectPath, or the cwd doesn't end with the expected suffix, we degrade to reading just the cwd's AGENTS.md instead of guessing at a parent root. Replaces the earlier doubled-path lookup in readSingleProjectContextInstructions (which always read the sub-project's AGENTS.md as if it were the parent's, and tried to read the sub-project's AGENTS.md from <cwd>/<rel>/<rel> which never existed) with a single inline helper that derives the parent root deterministically.
1 parent d31dbc9 commit f0de68e

4 files changed

Lines changed: 254 additions & 37 deletions

File tree

docs/agents/system-prompt.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,16 @@ Messages wrapped in <mux_subagent_report> are internal sub-agent outputs from Mu
7171

7272
/**
7373
* Build environment context XML block describing the workspace.
74-
* @param workspacePath - Workspace directory path
74+
*
75+
* Sub-project workspaces are framed identically to regular projects: the cwd
76+
* (already the sub-project directory thanks to resolveWorkspaceExecutionPath)
77+
* is presented as "the project" with no parent-repo callout. The agent does
78+
* not need to know about the parent's existence to do work — it just sees a
79+
* project rooted at this directory.
80+
*
81+
* @param workspacePath - Workspace directory path (cwd; for sub-projects this is already the sub-project path)
7582
* @param runtimeType - Runtime type (local, worktree, ssh, docker)
83+
* @param bestOf - Best-of grouping metadata for sibling sub-agent batches
7684
*/
7785
function buildEnvironmentContext(
7886
workspacePath: string,

src/node/services/agentSkills/builtInSkillContent.generated.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1585,8 +1585,16 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
15851585
"",
15861586
"/**",
15871587
" * Build environment context XML block describing the workspace.",
1588-
" * @param workspacePath - Workspace directory path",
1588+
" *",
1589+
" * Sub-project workspaces are framed identically to regular projects: the cwd",
1590+
" * (already the sub-project directory thanks to resolveWorkspaceExecutionPath)",
1591+
' * is presented as "the project" with no parent-repo callout. The agent does',
1592+
" * not need to know about the parent's existence to do work — it just sees a",
1593+
" * project rooted at this directory.",
1594+
" *",
1595+
" * @param workspacePath - Workspace directory path (cwd; for sub-projects this is already the sub-project path)",
15891596
" * @param runtimeType - Runtime type (local, worktree, ssh, docker)",
1597+
" * @param bestOf - Best-of grouping metadata for sibling sub-agent batches",
15901598
" */",
15911599
"function buildEnvironmentContext(",
15921600
" workspacePath: string,",

src/node/services/systemMessage.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,4 +540,172 @@ OpenAI-only instructions.
540540
});
541541
}
542542
});
543+
544+
describe("sub-project workspaces look like regular projects", () => {
545+
// Sub-project workspaces share the parent project's checkout but cwd into
546+
// a descendant directory. From the agent's perspective they should be
547+
// indistinguishable from a single-project workspace rooted at that cwd:
548+
// no parent-repo callout in the prompt, no inherited parent AGENTS.md,
549+
// no special "sub-project" framing in tool descriptions.
550+
async function setupSubProjectFixture(): Promise<{
551+
subProjectMetadata: WorkspaceMetadata;
552+
regularMetadata: WorkspaceMetadata;
553+
subProjectCwd: string;
554+
parentRoot: string;
555+
}> {
556+
const subProjectAbs = path.join(workspaceDir, "packages", "api");
557+
await fs.mkdir(subProjectAbs, { recursive: true });
558+
559+
const subProjectMetadata: WorkspaceMetadata = {
560+
id: "test-workspace",
561+
name: "test-workspace",
562+
projectName: "test-project",
563+
projectPath: workspaceDir,
564+
subProjectPath: subProjectAbs,
565+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
566+
};
567+
568+
// Regular single-project workspace whose project path IS the sub-project
569+
// directory. Used as the reference oracle: the sub-project workspace's
570+
// prompt at the same cwd must match this byte-for-byte in <environment>.
571+
const regularMetadata: WorkspaceMetadata = {
572+
id: "test-workspace",
573+
name: "test-workspace",
574+
projectName: "test-project",
575+
projectPath: subProjectAbs,
576+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
577+
};
578+
579+
return {
580+
subProjectMetadata,
581+
regularMetadata,
582+
subProjectCwd: subProjectAbs,
583+
parentRoot: workspaceDir,
584+
};
585+
}
586+
587+
test("environment block is identical to a regular single-project workspace at the same cwd", async () => {
588+
// Core invariant: presence of `subProjectPath` in metadata must not
589+
// change the <environment> block. The agent sees the same description
590+
// and lines whether the workspace is configured as a sub-project or as
591+
// a regular project rooted at that directory.
592+
const { subProjectMetadata, regularMetadata, subProjectCwd } = await setupSubProjectFixture();
593+
594+
const subProjectMessage = await buildSystemMessage(
595+
subProjectMetadata,
596+
runtime,
597+
subProjectCwd
598+
);
599+
const regularMessage = await buildSystemMessage(regularMetadata, runtime, subProjectCwd);
600+
601+
const subEnvironment = extractTagContent(subProjectMessage, "environment");
602+
const regularEnvironment = extractTagContent(regularMessage, "environment");
603+
expect(subEnvironment).toBe(regularEnvironment);
604+
});
605+
606+
test("environment block does not mention sub-project framing or relative-path nudges", async () => {
607+
// Regression guard against the rejected direction (PR #3244 v1) where
608+
// the prompt called out "the `packages/api` sub-project of the X at Y"
609+
// and added a relative-paths preamble. The agent should see the cwd as
610+
// a regular project root with no parent-repo context. (The parentRoot
611+
// is a path prefix of subProjectCwd, so checking for it directly is
612+
// ambiguous — the byte-equality test above already proves the env
613+
// block contains no parent-specific framing.)
614+
const { subProjectMetadata, subProjectCwd } = await setupSubProjectFixture();
615+
616+
const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd);
617+
const environment = extractTagContent(systemMessage, "environment") ?? "";
618+
619+
expect(environment).not.toContain("sub-project");
620+
expect(environment).not.toMatch(/Prefer paths relative to/);
621+
});
622+
623+
test("AGENTS.md is concatenated from parent then sub-project with no segment tagging", async () => {
624+
// Sub-projects inherit parent conventions: parent AGENTS.md is glued
625+
// before the sub-project's own AGENTS.md so the agent sees a single
626+
// combined block. No segment headings (`# Project context (root: ...)`,
627+
// `# Sub-project context (root: ...)`) are added — that would reveal
628+
// the sub-project structure to the agent, contradicting the
629+
// "regular project" framing the rest of the prompt commits to.
630+
const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture();
631+
632+
await fs.writeFile(
633+
path.join(parentRoot, "AGENTS.md"),
634+
"PARENT_MARKER: parent project conventions.\n"
635+
);
636+
await fs.writeFile(
637+
path.join(subProjectCwd, "AGENTS.md"),
638+
"SUB_MARKER: sub-project specific conventions.\n"
639+
);
640+
641+
const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd);
642+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
643+
644+
expect(customInstructions).toContain("PARENT_MARKER");
645+
expect(customInstructions).toContain("SUB_MARKER");
646+
// Parent first so general rules anchor before the more specific
647+
// sub-project overrides.
648+
expect(customInstructions.indexOf("PARENT_MARKER")).toBeLessThan(
649+
customInstructions.indexOf("SUB_MARKER")
650+
);
651+
// No segment headings — the agent should see a single AGENTS.md.
652+
expect(customInstructions).not.toContain("# Project context (root:");
653+
expect(customInstructions).not.toContain("# Sub-project context (root:");
654+
});
655+
656+
test("only-parent AGENTS.md is loaded when sub-project has none", async () => {
657+
// If only the parent has AGENTS.md, sub-project workspaces should
658+
// still inherit it.
659+
const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture();
660+
await fs.writeFile(
661+
path.join(parentRoot, "AGENTS.md"),
662+
"PARENT_ONLY_MARKER: only parent conventions.\n"
663+
);
664+
665+
const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd);
666+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
667+
668+
expect(customInstructions).toContain("PARENT_ONLY_MARKER");
669+
});
670+
671+
test("only-sub-project AGENTS.md is loaded when parent has none", async () => {
672+
// Symmetric: a sub-project with its own AGENTS.md but no parent
673+
// AGENTS.md should still load the sub-project's own.
674+
const { subProjectMetadata, subProjectCwd } = await setupSubProjectFixture();
675+
await fs.writeFile(
676+
path.join(subProjectCwd, "AGENTS.md"),
677+
"SUB_ONLY_MARKER: only sub-project conventions.\n"
678+
);
679+
680+
const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd);
681+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
682+
683+
expect(customInstructions).toContain("SUB_ONLY_MARKER");
684+
});
685+
686+
test("falls back to cwd-only AGENTS.md when sub-project metadata is stale", async () => {
687+
// If subProjectPath doesn't sit under projectPath (corrupted persisted
688+
// state, or a cwd that doesn't end with the expected suffix), we
689+
// can't safely derive the parent root. Degrade to reading just the
690+
// cwd's AGENTS.md instead of guessing at a parent root that might
691+
// pull in unrelated guidance.
692+
const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture();
693+
const stale = { ...subProjectMetadata, subProjectPath: "/elsewhere/api" };
694+
695+
await fs.writeFile(
696+
path.join(parentRoot, "AGENTS.md"),
697+
"PARENT_MARKER: should not be inherited from stale metadata.\n"
698+
);
699+
await fs.writeFile(
700+
path.join(subProjectCwd, "AGENTS.md"),
701+
"SUB_MARKER: should still appear.\n"
702+
);
703+
704+
const systemMessage = await buildSystemMessage(stale, runtime, subProjectCwd);
705+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
706+
707+
expect(customInstructions).not.toContain("PARENT_MARKER");
708+
expect(customInstructions).toContain("SUB_MARKER");
709+
});
710+
});
543711
});

src/node/services/systemMessage.ts

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,16 @@ Messages wrapped in <mux_subagent_report> are internal sub-agent outputs from Mu
9797

9898
/**
9999
* Build environment context XML block describing the workspace.
100-
* @param workspacePath - Workspace directory path
100+
*
101+
* Sub-project workspaces are framed identically to regular projects: the cwd
102+
* (already the sub-project directory thanks to resolveWorkspaceExecutionPath)
103+
* is presented as "the project" with no parent-repo callout. The agent does
104+
* not need to know about the parent's existence to do work — it just sees a
105+
* project rooted at this directory.
106+
*
107+
* @param workspacePath - Workspace directory path (cwd; for sub-projects this is already the sub-project path)
101108
* @param runtimeType - Runtime type (local, worktree, ssh, docker)
109+
* @param bestOf - Best-of grouping metadata for sibling sub-agent batches
102110
*/
103111
function buildEnvironmentContext(
104112
workspacePath: string,
@@ -334,50 +342,75 @@ async function readSingleProjectContextInstructions(
334342
runtime: Runtime,
335343
workspacePath: string
336344
): Promise<string | null> {
337-
// Read parent + sub-project AGENTS.md from the workspace's *own* checkout
338-
// (via the runtime). For worktree/SSH/Docker flows the parent project's host
339-
// path is a different checkout than the workspace branch — mixing the two
340-
// would inject contradictory or stale guidance and prevent workspace-branch
341-
// edits from overriding parent guidance. The workspace root is by
342-
// construction the parent project's checkout, and any registered
343-
// sub-project's relative path is stable across checkouts of the same repo.
344-
const subProjectRelativePath = metadata.subProjectPath
345-
? deriveSubProjectRelativePath(metadata.projectPath, metadata.subProjectPath)
346-
: null;
347-
348-
// path.relative emits host-native separators (e.g., "packages\\api" on Windows),
349-
// but SSH/Docker/devcontainer runtimes read files via POSIX paths. Normalize to
350-
// forward slashes and let the runtime joiner produce a runtime-correct path.
351-
const subProjectInstructionsDir = subProjectRelativePath
352-
? runtime.normalizePath(subProjectRelativePath.replace(/\\/g, "/"), workspacePath)
353-
: null;
354-
355-
const [parentInstructions, subProjectInstructions] = await Promise.all([
356-
readInstructionSetFromRuntime(runtime, workspacePath),
357-
subProjectInstructionsDir
358-
? readInstructionSetFromRuntime(runtime, subProjectInstructionsDir)
359-
: Promise.resolve(null),
360-
]);
345+
// The agent's view of a sub-project workspace is identical to a regular
346+
// project rooted at the sub-project cwd — same <environment> block, same
347+
// tool descriptions — but AGENTS.md is concatenated from parent then
348+
// sub-project so the agent inherits parent conventions transparently. The
349+
// concatenation is plain (no segment headings, no "Project context" /
350+
// "Sub-project context" tags) so the agent perceives a single AGENTS.md
351+
// just like any regular project's.
352+
//
353+
// Parent first, sub-project second: general rules anchor before more
354+
// specific sub-project overrides.
355+
//
356+
// For non-sub-project workspaces (or stale metadata that doesn't resolve
357+
// cleanly) this collapses to reading just the cwd — historical behavior.
358+
//
359+
// We always read the parent AGENTS.md from the workspace's *own* checkout
360+
// (i.e. derived from workspacePath, not from metadata.projectPath) so that
361+
// worktree/SSH/Docker workspaces see the parent guidance from their own
362+
// branch, not from the user's local checkout which may be on a different
363+
// commit.
364+
const parentRoot = deriveSubProjectParentRoot(metadata, workspacePath);
365+
366+
const segmentDirs: string[] = [];
367+
if (parentRoot && parentRoot !== workspacePath) {
368+
segmentDirs.push(parentRoot);
369+
}
370+
segmentDirs.push(workspacePath);
361371

362-
const contextSegments = [parentInstructions, subProjectInstructions].filter(
372+
const segments = await Promise.all(
373+
segmentDirs.map((dir) => readInstructionSetFromRuntime(runtime, dir))
374+
);
375+
const filtered = segments.filter(
363376
(segment): segment is string => segment != null && segment.trim().length > 0
364377
);
365-
return contextSegments.length > 0 ? contextSegments.join("\n\n") : null;
378+
return filtered.length > 0 ? filtered.join("\n\n") : null;
366379
}
367380

368381
/**
369-
* Compute the path of `subProjectPath` relative to `projectPath` for use under
370-
* the workspace's own checkout. Returns `null` if the recorded sub-project
371-
* path is not actually a descendant of the parent project (stale persisted
372-
* state) — callers should treat that as "no sub-project segment" and fall
373-
* back to parent-only instructions rather than failing.
382+
* Derive the parent project root for a sub-project workspace by stripping
383+
* the recorded sub-project relative segment off the cwd. Returns null when:
384+
*
385+
* - The workspace is not configured as a sub-project (no `subProjectPath`).
386+
* - The recorded sub-project path is not a descendant of the parent project
387+
* (stale metadata) — `path.relative` would emit `..` or an absolute path.
388+
* - The cwd doesn't end with the expected sub-project suffix — some other
389+
* resolution path produced the cwd, and we don't want to guess.
390+
*
391+
* Separator handling: local Windows runtimes produce backslash-separated
392+
* cwds (`C:\repo\packages\api`) while SSH/Docker/devcontainer runtimes use
393+
* forward slashes. We compare the suffix against a forward-slash
394+
* normalization of both sides so Windows cwds still match, then slice the
395+
* original `workspacePath` by length (segment counts and byte length match
396+
* between the two separator styles) so the returned parent root retains
397+
* the runtime-native separator style used everywhere else.
374398
*/
375-
function deriveSubProjectRelativePath(projectPath: string, subProjectPath: string): string | null {
376-
const relative = path.relative(projectPath, subProjectPath);
399+
function deriveSubProjectParentRoot(
400+
metadata: WorkspaceMetadata,
401+
workspacePath: string
402+
): string | null {
403+
const subProjectPath = metadata.subProjectPath?.trim();
404+
if (!subProjectPath) return null;
405+
const relative = path.relative(metadata.projectPath, subProjectPath);
377406
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
378407
return null;
379408
}
380-
return relative;
409+
const posixRelative = relative.replace(/\\/g, "/");
410+
const suffix = `/${posixRelative}`;
411+
const normalizedWorkspace = workspacePath.replace(/\\/g, "/");
412+
if (!normalizedWorkspace.endsWith(suffix)) return null;
413+
return workspacePath.slice(0, workspacePath.length - suffix.length) || "/";
381414
}
382415

383416
/**

0 commit comments

Comments
 (0)