feat(prompts): composeInstructions honors overrides — the P2 segment reconciliation
The other half-wired seam: Platform's settings 'agent.prompts' switches had no consumer in the agent layer, and their effectivePromptSegments mirrored the PRE-sources-v1 loader — the shared/ fragments dir (the learner-files doctrine) was invisible to it, so a naive delegation would have silently dropped the segment from both agents' prompts. - src/core/prompt-segments.ts (NEW, pure): the mirror-layout path grammar + segment types, defined exactly once; core stays zero-IO. - src/agent/prompt-segments.ts (NEW, the CANONICAL discovery): base + shared/ + per-agent fragments, override resolution, allSegmentPaths. Composition is an agent concern; the agent layer reads its own tree (the architecture law pins agent and store apart — the first direct refactor attempt tripped the enforced import/no-restricted-paths zone, hence the three-layer split). - src/store/prompt-overrides.ts: slimmed to the override-file CRUD behind the core port; shares the core grammar. Its doc no longer claims to be the canonical mirror. - teacher-agent: composeInstructions(kind, switches?) composes through effectivePromptSegments — a segment whose switch is ON and whose override file exists composes the override text. Switches-less calls use the cached pure-source composition (byte-identical to the old loader, pinned by test); AgentBuildOptions.promptSwitches threads effective.agent.prompts per turn, composing fresh so override-file writes flip the very next turn. - server barrel: re-exports the agent module's resolution for the settings routes (server→agent, the lawful direction). Tests: byte-identical source pin, override swap-in (on/off/file-absent), shared/ overridability in both agents, agent-construction threading, updated segment-list pins with shared/ in composition order. This file keeps owning PROMPTS_OVERRIDE_DIR (parallel-test discipline). Gates: 671/671, typecheck, lint, build.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* THE canonical prompt-segment discovery + override resolution (P2
|
||||
* reconciliation, 2026-09-19). The agent layer owns it because composition
|
||||
* lives here: composeInstructions (teacher-agent.ts) composes through
|
||||
* effectivePromptSegments, and the discovery reads THIS layer's own tree
|
||||
* (src/agent/prompts — base, shared/, per-agent fragments) — the exact
|
||||
* rules the pre-P2 loader used, now in one place.
|
||||
*
|
||||
* The architecture law pins the layout: the agent layer never imports the
|
||||
* store adapters, so the override-file READ lives here too (config-anchored
|
||||
* static file, same discipline as reading the source fragments); the
|
||||
* override-file CRUD (write/delete/list — the mutating store behind the
|
||||
* core port) stays in src/store/prompt-overrides.ts. Both sides share the
|
||||
* pure path grammar (src/core/prompt-segments.ts).
|
||||
*
|
||||
* SEGMENTS: the SHARED base (src/agent/system-prompt.md), the shared/
|
||||
* fragments directory (composed by BOTH agents between the base and their
|
||||
* own fragments — sources v1 2026-09-19), plus every numeric-prefix *.md
|
||||
* fragment in the agent's own directory, sorted by filename. The segment
|
||||
* list adapts to the code: an override keyed to a segment the source no
|
||||
* longer ships stops composing automatically.
|
||||
*
|
||||
* OVERRIDES: override files mirror the source layout under
|
||||
* config.PROMPTS_OVERRIDE_DIR — "system-prompt.md" at the root,
|
||||
* "shared/<file>", "teacher/<file>" / "prober/<file>". File presence is NOT
|
||||
* the override: the settings SWITCH is (owner decision) — the user keeps
|
||||
* their file and flips back to the source default to compare.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PATHS, PROMPTS_OVERRIDE_DIR } from "../../config.ts";
|
||||
import { FRAGMENT_RE, isValidSegmentPath, type PromptAgent, type PromptSegmentInfo } from "../core/prompt-segments.ts";
|
||||
|
||||
const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts");
|
||||
|
||||
/** Absolute path for an override file (unchecked — validate first). */
|
||||
function overrideFileFor(segmentPath: string): string {
|
||||
return path.join(PROMPTS_OVERRIDE_DIR, ...segmentPath.split("/"));
|
||||
}
|
||||
|
||||
/** Read one override file, or null when absent/invalid — the composition
|
||||
* side's reader (the store's readOverride is the same mirror layout,
|
||||
* kept separate because the layers may not import each other). */
|
||||
export function readPromptOverride(segmentPath: string): string | null {
|
||||
if (!isValidSegmentPath(segmentPath)) return null;
|
||||
try {
|
||||
return fs.readFileSync(overrideFileFor(segmentPath), "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** List the SOURCE segments for one agent, in composition order — the
|
||||
* shared base, then the shared/ fragments, then the agent's own sorted
|
||||
* by filename (the exact order composeInstructions composes them in). */
|
||||
export function listSourceSegments(agent: PromptAgent): Array<{ path: string; shared: boolean; file: string }> {
|
||||
const segments: Array<{ path: string; shared: boolean; file: string }> = [
|
||||
{ path: "system-prompt.md", shared: true, file: PATHS.teacherPrompt },
|
||||
];
|
||||
const sharedDir = path.join(PROMPTS_DIR, "shared");
|
||||
if (fs.existsSync(sharedDir)) {
|
||||
const sharedFragments = fs.readdirSync(sharedDir).filter((f) => FRAGMENT_RE.test(f)).sort();
|
||||
for (const f of sharedFragments) {
|
||||
segments.push({ path: `shared/${f}`, shared: true, file: path.join(sharedDir, f) });
|
||||
}
|
||||
}
|
||||
const dir = path.join(PROMPTS_DIR, agent);
|
||||
if (fs.existsSync(dir)) {
|
||||
const fragments = fs.readdirSync(dir).filter((f) => FRAGMENT_RE.test(f)).sort();
|
||||
for (const f of fragments) {
|
||||
segments.push({ path: `${agent}/${f}`, shared: false, file: path.join(dir, f) });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective segment view for one agent — every segment with its source
|
||||
* text, override text, and effective switch state. `switches` is the
|
||||
* settings record's agent.prompts map (absent key = off); enabled =
|
||||
* switch on AND the override file exists.
|
||||
*/
|
||||
export function effectivePromptSegments(
|
||||
agent: PromptAgent,
|
||||
switches: Record<string, boolean>,
|
||||
): PromptSegmentInfo[] {
|
||||
return listSourceSegments(agent).map((segment) => {
|
||||
let sourceText: string;
|
||||
try {
|
||||
sourceText = fs.readFileSync(segment.file, "utf-8");
|
||||
} catch {
|
||||
sourceText = ""; // source unreadable — the agent loader would crash anyway
|
||||
}
|
||||
const overrideText = readPromptOverride(segment.path);
|
||||
return {
|
||||
path: segment.path,
|
||||
sourceText,
|
||||
overrideText,
|
||||
enabled: switches[segment.path] === true && overrideText !== null,
|
||||
shared: segment.shared,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Every known segment path across both agents (route validation). */
|
||||
export function allSegmentPaths(): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const agent of ["prober", "teacher"] as const) {
|
||||
for (const segment of listSourceSegments(agent)) paths.add(segment.path);
|
||||
}
|
||||
return [...paths].sort();
|
||||
}
|
||||
Reference in New Issue
Block a user