Skip to content

Commit a6424bf

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). Each segment is wrapped with two markers: * A visible H1 heading naming the source path relative to the cwd (e.g. `` # `../../AGENTS.md` ``). The heading both names the segment's source AND bounds any scoped `## Tool:` / `## Model:` sections inside the segment so they can't span across the segment join into the next segment's narrative. * A markdown-invisible HTML comment with the same path injected after every ATX-style `## Tool:` / `## Model:` heading inside the segment. The H1 doesn't survive scoped extraction, so the inner comment carries the path provenance into per-tool/per- model contexts. Without this, `extractToolSection` would return bash bodies from both parent and sub-project with no way to tell which root was authored against which. - 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). Depth of the relative-path heading tracks the actual sub-project nesting depth. - 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 verbatim with no path-source heading or comment — historical behavior, unchanged. - Regular non-sub-project workspaces are unaffected: no path-source markers are emitted (one source = no ambiguity), preserving the exact prompt bytes for single-project workspaces. Also align extractModelSection's multi-match semantics with extractToolSection: collect every matching `## Model: …` section in source order and concatenate, instead of returning only the first match. Without this, sub-project model overrides were silently dropped when the parent AGENTS.md also defined a matching `## Model:` section — only the parent's body landed in the per-model section, and the sub-project's intended override never reached the agent. The change is principled (parallel to how tool overrides already compose for multi-project workspaces) and removes the now-unused internal extractSectionByHeading helper. 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 an inline helper that derives both the parent root and the relative-path hint deterministically.
1 parent d31dbc9 commit a6424bf

6 files changed

Lines changed: 602 additions & 41 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: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,4 +540,355 @@ 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 parent → sub-project with H1 path-source headings", 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. Each segment opens with an H1 heading whose body
627+
// is the source path relative to the cwd (e.g. `` # `../../AGENTS.md` ``
628+
// or `` # `./AGENTS.md` ``) so the agent can disambiguate which root
629+
// any relative path references in each segment should resolve
630+
// against. The H1 also bounds scoped `## Tool:` / `## Model:`
631+
// sections inside the segment, preventing them from spanning across
632+
// the segment join into the next segment's narrative.
633+
//
634+
// We deliberately avoid the rejected v1 framing of `# Project context
635+
// (root: ...)` / `# Sub-project context (root: ...)` — the H1 here is
636+
// just a path note, not a structural callout dressing the sub-project
637+
// up as a special feature.
638+
const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture();
639+
640+
await fs.writeFile(
641+
path.join(parentRoot, "AGENTS.md"),
642+
"PARENT_MARKER: parent project conventions.\n"
643+
);
644+
await fs.writeFile(
645+
path.join(subProjectCwd, "AGENTS.md"),
646+
"SUB_MARKER: sub-project specific conventions.\n"
647+
);
648+
649+
const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd);
650+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
651+
652+
expect(customInstructions).toContain("PARENT_MARKER");
653+
expect(customInstructions).toContain("SUB_MARKER");
654+
// `packages/api` is two levels deep, so the parent AGENTS.md is
655+
// `../../AGENTS.md` from the cwd.
656+
expect(customInstructions).toContain("# `../../AGENTS.md`");
657+
expect(customInstructions).toContain("# `./AGENTS.md`");
658+
// Each H1 must precede its corresponding content.
659+
expect(customInstructions.indexOf("# `../../AGENTS.md`")).toBeLessThan(
660+
customInstructions.indexOf("PARENT_MARKER")
661+
);
662+
expect(customInstructions.indexOf("# `./AGENTS.md`")).toBeLessThan(
663+
customInstructions.indexOf("SUB_MARKER")
664+
);
665+
// Parent first so general rules anchor before the more specific
666+
// sub-project overrides.
667+
expect(customInstructions.indexOf("PARENT_MARKER")).toBeLessThan(
668+
customInstructions.indexOf("SUB_MARKER")
669+
);
670+
// Regression guard against the rejected v1 verbose framing.
671+
expect(customInstructions).not.toContain("# Project context (root:");
672+
expect(customInstructions).not.toContain("# Sub-project context (root:");
673+
});
674+
675+
test("relative-path heading depth tracks the sub-project nesting depth", async () => {
676+
// A single-segment sub-project (`api` instead of `packages/api`) only
677+
// needs one `../` level. Verifies the depth computation rather than
678+
// hard-coding the fixture's two-level structure.
679+
const subProjectAbs = path.join(workspaceDir, "api");
680+
await fs.mkdir(subProjectAbs, { recursive: true });
681+
const metadata: WorkspaceMetadata = {
682+
id: "test-workspace",
683+
name: "test-workspace",
684+
projectName: "test-project",
685+
projectPath: workspaceDir,
686+
subProjectPath: subProjectAbs,
687+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
688+
};
689+
690+
await fs.writeFile(
691+
path.join(workspaceDir, "AGENTS.md"),
692+
"DEPTH_TEST_PARENT_MARKER: depth=1 parent.\n"
693+
);
694+
695+
const systemMessage = await buildSystemMessage(metadata, runtime, subProjectAbs);
696+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
697+
698+
expect(customInstructions).toContain("# `../AGENTS.md`");
699+
expect(customInstructions).not.toContain("# `../../AGENTS.md`");
700+
expect(customInstructions).toContain("DEPTH_TEST_PARENT_MARKER");
701+
});
702+
703+
test("only-parent AGENTS.md is loaded when sub-project has none", async () => {
704+
// If only the parent has AGENTS.md, sub-project workspaces should
705+
// still inherit it — and the H1 path heading lets the agent know
706+
// it's reading the parent's, not its own.
707+
const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture();
708+
await fs.writeFile(
709+
path.join(parentRoot, "AGENTS.md"),
710+
"PARENT_ONLY_MARKER: only parent conventions.\n"
711+
);
712+
713+
const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd);
714+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
715+
716+
expect(customInstructions).toContain("PARENT_ONLY_MARKER");
717+
expect(customInstructions).toContain("# `../../AGENTS.md`");
718+
expect(customInstructions).not.toContain("# `./AGENTS.md`");
719+
});
720+
721+
test("only-sub-project AGENTS.md is loaded when parent has none", async () => {
722+
// Symmetric: a sub-project with its own AGENTS.md but no parent
723+
// AGENTS.md should still load the sub-project's own with the matching
724+
// `./AGENTS.md` heading.
725+
const { subProjectMetadata, subProjectCwd } = await setupSubProjectFixture();
726+
await fs.writeFile(
727+
path.join(subProjectCwd, "AGENTS.md"),
728+
"SUB_ONLY_MARKER: only sub-project conventions.\n"
729+
);
730+
731+
const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd);
732+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
733+
734+
expect(customInstructions).toContain("SUB_ONLY_MARKER");
735+
expect(customInstructions).toContain("# `./AGENTS.md`");
736+
expect(customInstructions).not.toContain("# `../../AGENTS.md`");
737+
});
738+
739+
test("regular non-sub-project workspaces emit no path-source heading", async () => {
740+
// Non-sub-project workspaces preserve historical behavior: the cwd's
741+
// AGENTS.md is loaded verbatim with no path-source heading or
742+
// comment. Otherwise we'd be subtly changing the prompt for every
743+
// regular project workspace.
744+
const { regularMetadata, subProjectCwd } = await setupSubProjectFixture();
745+
await fs.writeFile(
746+
path.join(subProjectCwd, "AGENTS.md"),
747+
"REGULAR_MARKER: regular project conventions.\n"
748+
);
749+
750+
const systemMessage = await buildSystemMessage(regularMetadata, runtime, subProjectCwd);
751+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
752+
753+
expect(customInstructions).toContain("REGULAR_MARKER");
754+
expect(customInstructions).not.toContain("# `");
755+
expect(customInstructions).not.toContain("<!--");
756+
});
757+
758+
test("path-source comment is injected inside scoped Tool: / Model: sections so they survive extraction", async () => {
759+
// Codex-flagged regression (PR #3244): `extractToolSection` /
760+
// `extractModelSection` pull the body of a `## Tool: bash` /
761+
// `## Model: …` section out of the segment, but the top-level
762+
// `<!-- ../../AGENTS.md -->` comment lives BEFORE the section and
763+
// isn't part of the extracted body. Without injecting the comment
764+
// inside each scoped section, scoped tool/model instructions lose
765+
// the parent-vs-subproject provenance — defeating the purpose of
766+
// the comment for any path-sensitive guidance authored under a
767+
// scoped heading.
768+
//
769+
// The injection must also play nicely with
770+
// `stripScopedInstructionSections`, which deletes the entire
771+
// scoped section from <custom-instructions>: the inner comment
772+
// disappears with it (no leftover comment alone), while the
773+
// top-level comment for the surviving narrative still anchors
774+
// the segment.
775+
const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture();
776+
777+
await fs.writeFile(
778+
path.join(parentRoot, "AGENTS.md"),
779+
`Parent narrative.
780+
781+
## Tool: bash
782+
PARENT_BASH_RULE: parent's bash convention.
783+
784+
## Model: anthropic:claude-sonnet-4-20250514
785+
PARENT_MODEL_RULE: parent model preference.
786+
`
787+
);
788+
await fs.writeFile(
789+
path.join(subProjectCwd, "AGENTS.md"),
790+
`Sub-project narrative.
791+
792+
## Tool: bash
793+
SUB_BASH_RULE: sub-project's bash convention.
794+
795+
## Model: anthropic:claude-sonnet-4-20250514
796+
SUB_MODEL_RULE: sub-project model override.
797+
`
798+
);
799+
800+
const modelString = "anthropic:claude-sonnet-4-20250514";
801+
const toolInstructions = await readToolInstructions(
802+
subProjectMetadata,
803+
runtime,
804+
subProjectCwd,
805+
modelString
806+
);
807+
808+
const bash = toolInstructions.bash ?? "";
809+
// Both bash bodies must appear in the per-tool extraction with their
810+
// respective path-source comments.
811+
expect(bash).toContain("PARENT_BASH_RULE");
812+
expect(bash).toContain("<!-- ../../AGENTS.md -->");
813+
expect(bash).toContain("SUB_BASH_RULE");
814+
expect(bash).toContain("<!-- ./AGENTS.md -->");
815+
// Each comment precedes its corresponding rule so the agent reads
816+
// "this rule comes from this path" linearly.
817+
expect(bash.indexOf("<!-- ../../AGENTS.md -->")).toBeLessThan(
818+
bash.indexOf("PARENT_BASH_RULE")
819+
);
820+
expect(bash.indexOf("<!-- ./AGENTS.md -->")).toBeLessThan(bash.indexOf("SUB_BASH_RULE"));
821+
822+
const systemMessage = await buildSystemMessage(
823+
subProjectMetadata,
824+
runtime,
825+
subProjectCwd,
826+
undefined,
827+
modelString
828+
);
829+
830+
// Codex-flagged regression (PR #3244): when both parent and sub-project
831+
// define matching `## Model: ...` sections, both bodies must appear in
832+
// the per-model section (the previous singular `extractModelSection`
833+
// returned only the parent's, silently dropping the sub-project's
834+
// override). Sub-project body comes second so its rule overrides the
835+
// parent's via order of presentation.
836+
const sonnetSection =
837+
extractTagContent(systemMessage, "model-anthropic-claude-sonnet-4-20250514") ?? "";
838+
expect(sonnetSection).toContain("PARENT_MODEL_RULE");
839+
expect(sonnetSection).toContain("SUB_MODEL_RULE");
840+
expect(sonnetSection).toContain("<!-- ../../AGENTS.md -->");
841+
expect(sonnetSection).toContain("<!-- ./AGENTS.md -->");
842+
expect(sonnetSection.indexOf("PARENT_MODEL_RULE")).toBeLessThan(
843+
sonnetSection.indexOf("SUB_MODEL_RULE")
844+
);
845+
846+
// Sanity check: <custom-instructions> still strips scoped sections
847+
// (including the inner comment) so the bash/model rules don't leak
848+
// into the unscoped instructions block.
849+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
850+
expect(customInstructions).not.toContain("PARENT_BASH_RULE");
851+
expect(customInstructions).not.toContain("SUB_BASH_RULE");
852+
expect(customInstructions).not.toContain("PARENT_MODEL_RULE");
853+
expect(customInstructions).not.toContain("SUB_MODEL_RULE");
854+
// Top-level H1 path headings survive (they're outside scoped sections,
855+
// and they bound the scoped sections to their own segment so the
856+
// parent's `## Model: ...` can't sweep the sub-project's narrative
857+
// into its strip range).
858+
expect(customInstructions).toContain("# `../../AGENTS.md`");
859+
expect(customInstructions).toContain("# `./AGENTS.md`");
860+
// Inner HTML comments inside scoped sections must NOT leak into the
861+
// <custom-instructions> block: they're stripped along with their
862+
// section.
863+
expect(customInstructions).not.toContain("<!-- ../../AGENTS.md -->");
864+
expect(customInstructions).not.toContain("<!-- ./AGENTS.md -->");
865+
});
866+
867+
test("falls back to cwd-only AGENTS.md when sub-project metadata is stale", async () => {
868+
// If subProjectPath doesn't sit under projectPath (corrupted persisted
869+
// state, or a cwd that doesn't end with the expected suffix), we
870+
// can't safely derive the parent root. Degrade to reading just the
871+
// cwd's AGENTS.md — historical behavior — with no path-source comment
872+
// since there's only one source.
873+
const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture();
874+
const stale = { ...subProjectMetadata, subProjectPath: "/elsewhere/api" };
875+
876+
await fs.writeFile(
877+
path.join(parentRoot, "AGENTS.md"),
878+
"PARENT_MARKER: should not be inherited from stale metadata.\n"
879+
);
880+
await fs.writeFile(
881+
path.join(subProjectCwd, "AGENTS.md"),
882+
"SUB_MARKER: should still appear.\n"
883+
);
884+
885+
const systemMessage = await buildSystemMessage(stale, runtime, subProjectCwd);
886+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
887+
888+
expect(customInstructions).not.toContain("PARENT_MARKER");
889+
expect(customInstructions).toContain("SUB_MARKER");
890+
expect(customInstructions).not.toContain("# `");
891+
expect(customInstructions).not.toContain("<!--");
892+
});
893+
});
543894
});

0 commit comments

Comments
 (0)