Skip to content

Commit ccf1307

Browse files
committed
feat: director prompt render-style switch (markdown default, xml experiment)
1 parent 2c38c6f commit ccf1307

5 files changed

Lines changed: 374 additions & 10 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
3+
import { DIRECTOR_REGISTRY } from "./registry.js";
4+
import { DIRECTOR_IDS } from "./types.js";
5+
import { formatDirectorSystemPrompt } from "./identity.js";
6+
import {
7+
FALLBACK_SECTION_TAG,
8+
normalizeSectionTag,
9+
PROMPT_RENDER_STYLE_ENV_VAR,
10+
PROMPT_SECTION_TAG_VOCABULARY,
11+
renderDirectorSystemPrompt,
12+
renderPromptBody,
13+
resetCachedPromptRenderStyleForTests,
14+
resolvePromptRenderStyle,
15+
splitPromptSections,
16+
tagForSectionTitle,
17+
} from "./prompt-sections.js";
18+
19+
const savedEnv = process.env[PROMPT_RENDER_STYLE_ENV_VAR];
20+
21+
afterEach(() => {
22+
// "" resolves like unset (markdown); `delete` on a computed key is
23+
// lint-banned, so an empty assignment stands in for a missing var.
24+
process.env[PROMPT_RENDER_STYLE_ENV_VAR] = savedEnv ?? "";
25+
resetCachedPromptRenderStyleForTests();
26+
});
27+
28+
function tagNames(xml: string): string[] {
29+
return [...xml.matchAll(/<([a-z][a-z0-9_]*)>/g)].map((m) => m[1] ?? "");
30+
}
31+
32+
describe("director prompt render style", () => {
33+
test("default arm is byte-identical to formatDirectorSystemPrompt for every director", () => {
34+
process.env[PROMPT_RENDER_STYLE_ENV_VAR] = "";
35+
resetCachedPromptRenderStyleForTests();
36+
for (const id of DIRECTOR_IDS) {
37+
const pkg = DIRECTOR_REGISTRY[id];
38+
expect(renderDirectorSystemPrompt(pkg)).toBe(
39+
formatDirectorSystemPrompt(pkg),
40+
);
41+
expect(renderDirectorSystemPrompt(pkg, "markdown")).toBe(
42+
formatDirectorSystemPrompt(pkg),
43+
);
44+
expect(renderPromptBody(pkg.systemPrompt, "markdown")).toBe(
45+
pkg.systemPrompt,
46+
);
47+
}
48+
});
49+
50+
test("xml arm keeps balanced tags with one counterpart per markdown section", () => {
51+
for (const id of DIRECTOR_IDS) {
52+
const pkg = DIRECTOR_REGISTRY[id];
53+
const full = formatDirectorSystemPrompt(pkg);
54+
const xml = renderDirectorSystemPrompt(pkg, "xml");
55+
const { sections } = splitPromptSections(full);
56+
if (sections.length === 0) {
57+
// Section-less directors render identically in both arms.
58+
expect(xml).toBe(full);
59+
continue;
60+
}
61+
// Every markdown section has exactly one xml counterpart.
62+
expect(tagNames(xml)).toHaveLength(sections.length);
63+
// Balanced: every open has its close, no heading lines survive.
64+
for (const tag of new Set(tagNames(xml))) {
65+
const opens = xml.split(`<${tag}>`).length - 1;
66+
const closes = xml.split(`</${tag}>`).length - 1;
67+
expect(opens).toBeGreaterThan(0);
68+
expect(closes).toBe(opens);
69+
}
70+
expect(xml).not.toMatch(/^#{1,2} /m);
71+
// Fixed vocabulary: every emitted tag is known or the fallback.
72+
const known = new Set<string>([
73+
...PROMPT_SECTION_TAG_VOCABULARY,
74+
FALLBACK_SECTION_TAG,
75+
]);
76+
for (const tag of tagNames(xml)) expect(known.has(tag)).toBe(true);
77+
}
78+
});
79+
80+
test("fixed vocabulary covers every shipped section title", () => {
81+
const known: ReadonlySet<string> = new Set(PROMPT_SECTION_TAG_VOCABULARY);
82+
for (const id of DIRECTOR_IDS) {
83+
const { sections } = splitPromptSections(
84+
formatDirectorSystemPrompt(DIRECTOR_REGISTRY[id]),
85+
);
86+
for (const section of sections) {
87+
expect(known.has(tagForSectionTitle(section.title))).toBe(true);
88+
}
89+
}
90+
});
91+
92+
test("style resolves once per session and defaults to markdown", () => {
93+
process.env[PROMPT_RENDER_STYLE_ENV_VAR] = "";
94+
resetCachedPromptRenderStyleForTests();
95+
expect(resolvePromptRenderStyle()).toBe("markdown");
96+
97+
process.env[PROMPT_RENDER_STYLE_ENV_VAR] = "xml";
98+
resetCachedPromptRenderStyleForTests();
99+
expect(resolvePromptRenderStyle()).toBe("xml");
100+
// Mid-session flips do not take effect: the cache prefix holds.
101+
process.env[PROMPT_RENDER_STYLE_ENV_VAR] = "markdown";
102+
expect(resolvePromptRenderStyle()).toBe("xml");
103+
104+
process.env[PROMPT_RENDER_STYLE_ENV_VAR] = "no-such-style";
105+
resetCachedPromptRenderStyleForTests();
106+
expect(resolvePromptRenderStyle()).toBe("markdown");
107+
});
108+
109+
test("normalizeSectionTag handles digits, punctuation, and empties", () => {
110+
expect(normalizeSectionTag("Prerequisites")).toBe("prerequisites");
111+
expect(normalizeSectionTag("0. Document discovery")).toBe(
112+
"sec_0_document_discovery",
113+
);
114+
expect(
115+
normalizeSectionTag(
116+
"If IMPLEMENTATION → DIY when tiny; spawn when substantial",
117+
),
118+
).toBe("if_implementation_diy_when_tiny_spawn_when_substantial");
119+
expect(normalizeSectionTag("")).toBe(FALLBACK_SECTION_TAG);
120+
expect(tagForSectionTitle("a title no package uses")).toBe(
121+
FALLBACK_SECTION_TAG,
122+
);
123+
});
124+
});
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Section registry for director prompt assembly (CL-8685 render-style experiment).
2+
//
3+
// Every director system prompt is a preamble plus `#`/`##` sections. The
4+
// markdown arm renders sections as `## Title` (byte-identical to the shipped
5+
// prompts — the markdown path returns `formatDirectorSystemPrompt` untouched);
6+
// the xml arm renders those same sections as `<tag>body</tag>` with a tag
7+
// from the fixed vocabulary below. Tags wrap whole sections only: inline
8+
// prose, deeper sub-headings (`###` and below pass through verbatim), and the
9+
// identity header (no `#` headings, so it stays preamble) are never tagged.
10+
//
11+
// Switch: `CORBITS_PROMPT_RENDER_STYLE=markdown|xml` (default `markdown`).
12+
// The style resolves once per process and is cached, so the prompt-cache
13+
// prefix cannot split mid-session. Tests reset the cache via
14+
// `resetCachedPromptRenderStyleForTests`.
15+
16+
import type { DirectorPackage } from "./types.js";
17+
import { formatDirectorSystemPrompt } from "./identity.js";
18+
19+
export type PromptRenderStyle = "markdown" | "xml";
20+
21+
/** Environment variable selecting the director-prompt render style. */
22+
export const PROMPT_RENDER_STYLE_ENV_VAR = "CORBITS_PROMPT_RENDER_STYLE";
23+
24+
// Fixed tag vocabulary: one tag per `#`/`##` section title shipped in
25+
// `src/agent/directors/*/package.ts`, normalized as `normalizeSectionTag`
26+
// does. Titles outside this set (future packages) fall back to `section`.
27+
export const PROMPT_SECTION_TAG_VOCABULARY = [
28+
"acknowledgment",
29+
"actually_overall_assessment",
30+
"anti_cascade_stall_dig_diagnose",
31+
"architecture_recommendations_all_terrible",
32+
"architecture_review_criteria",
33+
"blockers",
34+
"build_a_shared_glossary",
35+
"build_gate",
36+
"capabilities",
37+
"cargo_cult_programming_patterns_you_re_missing",
38+
"case_template",
39+
"common_neckbeard_phrases",
40+
"corbits_report_shape",
41+
"critical_reminder",
42+
"cross_document_analysis",
43+
"design_in_report_workflow",
44+
"document_discovery",
45+
"document_types",
46+
"documents_not_found",
47+
"effort_scaling_implementation_orchestration",
48+
"error_handling",
49+
"execution_steps",
50+
"fetch_urls_primary_mounted",
51+
"final_recommendation",
52+
"findings",
53+
"findings_route_to_follow_ups_never_silent_retunes",
54+
"guidelines",
55+
"harness_consume_do_not_build_a_second_one",
56+
"hold_the_line_on_scope",
57+
"how_you_work",
58+
"if_communication_answer_directly",
59+
"if_implementation_diy_when_tiny_spawn_when_substantial",
60+
"if_orchestration_coordinate",
61+
"implement_and_test",
62+
"implementation_review_criteria",
63+
"incomplete_document_set",
64+
"insufferable_details_x_found",
65+
"insufferable_mode_default",
66+
"key_suggestions_all_terrible",
67+
"maddening_nitpicks_x_found",
68+
"malformed_documents",
69+
"neckbeard_perspective",
70+
"neckbeard_review_project_name",
71+
"neckbeard_review_project_name_maximum_pedantry_edition",
72+
"non_negotiables",
73+
"operator_updates_mandatory_while_fleet_is_live",
74+
"out_of_lane",
75+
"parent_tools",
76+
"paths",
77+
"peak_neckbeard_issues_x_found",
78+
"plan",
79+
"prerequisites",
80+
"product_review_criteria",
81+
"protocol_in_order_no_shortcuts",
82+
"push_back_on_the_vision_itself_gently",
83+
"recommendation",
84+
"remember",
85+
"report",
86+
"report_contract",
87+
"report_shape",
88+
"report_when_dispatched_as_a_worker",
89+
"reporting_back",
90+
"request_shape_implementation_orchestration_communication",
91+
"review_framework",
92+
"review_modes",
93+
"riff_first",
94+
"risk_prioritization",
95+
"rules",
96+
"sec_0_document_discovery",
97+
"sec_1_analyze_and_classify_input",
98+
"sec_2_route_and_deepen",
99+
"sec_3_update_document",
100+
"sec_4_cross_document_consistency_significant_only",
101+
"sec_5_gap_detection_significant_only",
102+
"sec_6_report",
103+
"session_initialization",
104+
"spawn_graph",
105+
"spawn_handoff",
106+
"stay_in_lane",
107+
"step_1_load_prerequisites",
108+
"step_2_discover_documents_and_code_when_asked",
109+
"step_3_determine_review_mode",
110+
"step_4_read_all_targets",
111+
"step_5_perform_analysis",
112+
"step_6_synthesize_nitpicks",
113+
"step_7_report_nitpicks",
114+
"summary",
115+
"tools",
116+
"unbearable_issues_x_found",
117+
"utterly_unbearable_mode",
118+
"verify_after_ship",
119+
"voice",
120+
"what_to_measure",
121+
"what_you_do_not_do",
122+
"who_you_are",
123+
"workflow_scribe_core",
124+
"write_the_brief_when_it_is_ready",
125+
"your_role",
126+
] as const;
127+
128+
const SECTION_TAG_SET: ReadonlySet<string> = new Set(
129+
PROMPT_SECTION_TAG_VOCABULARY,
130+
);
131+
132+
/** Fallback tag for section titles outside the fixed vocabulary. */
133+
export const FALLBACK_SECTION_TAG = "section";
134+
135+
export interface PromptSection {
136+
readonly title: string;
137+
readonly level: 1 | 2;
138+
readonly body: string;
139+
}
140+
141+
export interface SplitPrompt {
142+
readonly preamble: string;
143+
readonly sections: readonly PromptSection[];
144+
}
145+
146+
// Section boundary: an ATX `#` or `##` heading line. `###` and deeper are
147+
// sub-section detail and never split — tags stay at section granularity.
148+
const SECTION_HEADING_PATTERN = /^#{1,2} ([^\n]*)(?:\n|$)/gm;
149+
150+
/** Split prompt text into its preamble and `#`/`##` sections (offsets preserved). */
151+
export function splitPromptSections(text: string): SplitPrompt {
152+
const matches = [...text.matchAll(SECTION_HEADING_PATTERN)];
153+
if (matches.length === 0) return { preamble: text, sections: [] };
154+
const first = matches[0];
155+
if (first === undefined || first.index === undefined)
156+
return { preamble: text, sections: [] };
157+
const preamble = text.slice(0, first.index);
158+
const sections = matches.map((match, i) => {
159+
const level = match[0].startsWith("## ") ? 2 : 1;
160+
const next = matches[i + 1];
161+
const bodyStart = (match.index ?? 0) + match[0].length;
162+
const bodyEnd = next?.index ?? text.length;
163+
return {
164+
title: (match[1] ?? "").trim(),
165+
level: level as 1 | 2,
166+
body: text.slice(bodyStart, bodyEnd),
167+
};
168+
});
169+
return { preamble, sections };
170+
}
171+
172+
// Normalize a section title to its tag: lowercase, runs of non-alphanumerics
173+
// to `_`, trimmed. Leading-digit results take a `sec_` prefix (bare digits
174+
// are not valid XML tag starts); empty results take the fallback tag.
175+
export function normalizeSectionTag(title: string): string {
176+
const base = title
177+
.toLowerCase()
178+
.replace(/[^a-z0-9]+/g, "_")
179+
.replace(/^_+|_+$/g, "");
180+
if (base === "") return FALLBACK_SECTION_TAG;
181+
return /^[0-9]/.test(base) ? `sec_${base}` : base;
182+
}
183+
184+
/** Map a section title to its render tag: vocabulary hit, else the fallback. */
185+
export function tagForSectionTitle(title: string): string {
186+
const tag = normalizeSectionTag(title);
187+
return SECTION_TAG_SET.has(tag) ? tag : FALLBACK_SECTION_TAG;
188+
}
189+
190+
/**
191+
* Render prompt text in a style. Markdown returns the input untouched
192+
* (byte-identical by construction); xml wraps each section body in its tag.
193+
*/
194+
export function renderPromptBody(
195+
text: string,
196+
style: PromptRenderStyle,
197+
): string {
198+
if (style === "markdown") return text;
199+
const { preamble, sections } = splitPromptSections(text);
200+
if (sections.length === 0) return text;
201+
const parts = preamble.trim() === "" ? [] : [preamble.trim()];
202+
for (const section of sections) {
203+
const tag = tagForSectionTitle(section.title);
204+
parts.push(`<${tag}>\n${section.body.trim()}\n</${tag}>`);
205+
}
206+
return parts.join("\n\n");
207+
}
208+
209+
let cachedStyle: PromptRenderStyle | undefined;
210+
211+
/**
212+
* Resolve the render style once per process and cache it: the director prompt
213+
* prefix must not change mid-session or the prompt cache splits. Unknown and
214+
* absent values fall back to markdown.
215+
*/
216+
export function resolvePromptRenderStyle(
217+
env: Record<string, string | undefined> = process.env,
218+
): PromptRenderStyle {
219+
if (cachedStyle === undefined) {
220+
const raw = env[PROMPT_RENDER_STYLE_ENV_VAR]?.trim().toLowerCase();
221+
cachedStyle = raw === "xml" ? "xml" : "markdown";
222+
}
223+
return cachedStyle;
224+
}
225+
226+
/** Test-only reset for the once-per-session style cache. */
227+
export function resetCachedPromptRenderStyleForTests(): void {
228+
cachedStyle = undefined;
229+
}
230+
231+
/**
232+
* Render a director package system prompt (identity header + body) in a
233+
* style. The default arm resolves the once-per-session style; markdown is
234+
* `formatDirectorSystemPrompt` verbatim.
235+
*/
236+
export function renderDirectorSystemPrompt(
237+
pkg: DirectorPackage,
238+
style: PromptRenderStyle = resolvePromptRenderStyle(),
239+
): string {
240+
const full = formatDirectorSystemPrompt(pkg);
241+
return style === "xml" ? renderPromptBody(full, "xml") : full;
242+
}

‎src/agent/directors/registry.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { testsmithPackage } from "./testsmith/package.js";
1919
import { gauntletPackage } from "./gauntlet/package.js";
2020
import { proberPackage } from "./prober/package.js";
2121
import { wardenPackage } from "./warden/package.js";
22-
import { formatDirectorSystemPrompt } from "./identity.js";
22+
import { renderDirectorSystemPrompt } from "./prompt-sections.js";
2323
import {
2424
DIRECTOR_IDS,
2525
type DirectorId,
@@ -144,7 +144,7 @@ export function packageToProfile(pkg: DirectorPackage): AgentProfile {
144144
return {
145145
id: pkg.id,
146146
description: `${pkg.description} (agent id: ${pkg.id})`,
147-
systemPromptRole: formatDirectorSystemPrompt(pkg),
147+
systemPromptRole: renderDirectorSystemPrompt(pkg),
148148
// Nested spawn is still gated by allowOrchestrator on the parent fleet tools.
149149
// Skywalker maySpawn marks intent; leaves stay non-orchestrator.
150150
orchestrator: pkg.spawn.maySpawn,

0 commit comments

Comments
 (0)