From 524bf6024a368e1f007ddced9f6d1878b579f722 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 18:21:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(prompts):=20composeInstructions=20honors?= =?UTF-8?q?=20overrides=20=E2=80=94=20the=20P2=20segment=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/agent/prompt-segments.ts | 113 +++++++++++++++++++++++++++++ src/agent/teacher-agent.ts | 64 ++++++++++------ src/core/index.ts | 1 + src/core/prompt-segments.ts | 47 ++++++++++++ src/server/chat-handler.ts | 4 + src/server/index.ts | 12 +-- src/store/prompt-overrides.ts | 129 ++++++--------------------------- tests/prompt-overrides.test.ts | 110 +++++++++++++++++++++++++--- 8 files changed, 333 insertions(+), 147 deletions(-) create mode 100644 src/agent/prompt-segments.ts create mode 100644 src/core/prompt-segments.ts diff --git a/src/agent/prompt-segments.ts b/src/agent/prompt-segments.ts new file mode 100644 index 0000000..acbe8f1 --- /dev/null +++ b/src/agent/prompt-segments.ts @@ -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/", "teacher/" / "prober/". 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, +): 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(); + for (const agent of ["prober", "teacher"] as const) { + for (const segment of listSourceSegments(agent)) paths.add(segment.path); + } + return [...paths].sort(); +} diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index e0630c8..805892b 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -1,11 +1,10 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; import { PATHS } from "../../config.ts"; import { MODELS } from "../../config.ts"; import { piEngineFor, thinkingLevelFor, openRouterApiKey, type PiEngine } from "./models.ts"; +import { effectivePromptSegments } from "./prompt-segments.ts"; import { askUserQuestionTool, createProberTools, @@ -42,32 +41,40 @@ import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; /** Which agent's prompt directory to compose. */ export type AgentKind = "prober" | "teacher"; -const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts"); - -/** A prompt fragment: numeric order key + .md (e.g. 20-teaching.md). Nothing else loads. */ -const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/; - -/** Load one directory's fragments (sorted filename order, trimmed, - * empty ones dropped) — the loader enforces the numeric prefix. */ -function loadFragments(dir: string): string[] { - if (!fs.existsSync(dir)) return []; - return fs - .readdirSync(dir) - .filter((f) => FRAGMENT_RE.test(f)) - .sort() - .map((f) => fs.readFileSync(path.join(dir, f), "utf-8").trim()) - .filter(Boolean); -} +// P2 (2026-09-19): segment discovery + override resolution live in ONE +// canonical place now — src/store/prompt-overrides.ts (listSourceSegments +// mirrors this loader's rules, shared/ included). The agent imports it for +// the static-file seam; the local fragment discovery helpers are retired. /** Exported for tests: assert what composes into the model's prompt. * Composition order: shared base → shared/ fragments (both agents, the - * sources-v1 seam for cross-agent doctrine) → the agent's own fragments. */ -export function composeInstructions(kind: AgentKind): string { - const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8"); - const fragments = [...loadFragments(path.join(PROMPTS_DIR, "shared")), ...loadFragments(path.join(PROMPTS_DIR, kind))]; - return [base.trim(), ...fragments].join("\n\n---\n\n"); + * sources-v1 seam for cross-agent doctrine) → the agent's own fragments. + * + * P2 reconciliation (2026-09-19): composition is DELEGATED to the store + * module's effectivePromptSegments — one canonical segment-discovery + * rule (its listSourceSegments mirrors this loader's, now including + * shared/) — so per-segment OVERRIDES ride the same path: a segment + * whose switch is ON and whose override file exists composes the + * override text instead of the source. `switches` is the settings + * record's agent.prompts map (absent key = off), threaded per turn via + * AgentBuildOptions.promptSwitches; a switches-less call composes pure + * source (byte-identical to the pre-refactor loader — pinned in + * tests/prompt-overrides.test.ts). Overrides are a static-file read + * through config paths, not request state — the store import carries no + * chat/job machinery. */ +export function composeInstructions(kind: AgentKind, switches?: Record): string { + const segments = effectivePromptSegments(kind, switches ?? {}); + return segments + .map((s) => (s.enabled ? s.overrideText! : s.sourceText)) + .map((t) => t.trim()) + .filter(Boolean) + .join("\n\n---\n\n"); } +// The switch-free source composition, cached per kind (module-load once). +// With promptSwitches provided, baseAgentOptions composes FRESH per turn — +// an override file written while its switch is ON must flip the next +// turn's composition; the cache serves only the switches-less path. const composedInstructions: Record = { prober: composeInstructions("prober"), teacher: composeInstructions("teacher"), @@ -97,6 +104,12 @@ export interface AgentBuildOptions { * THE DEFINITIVE toggleable set is TOGGLEABLE_TOOLS (job tools only); * the filtering is generic, but writes validate against the list). */ toolToggles?: EnabledTools; + /** Per-segment system-prompt override SWITCHES (the settings record's + * agent.prompts map, absent key = off — same keys the settings routes + * and effectivePromptSegments use). Provided → the prompt composes + * fresh per turn, honoring enabled overrides (switch on + file + * exists); absent → the cached pure-source composition. */ + promptSwitches?: Record; } /** @@ -133,9 +146,12 @@ function makeStopper(maxTurns: number) { function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool[]) { const engine = opts.engine ?? piEngineFor(kind); const stopper = makeStopper(opts.maxTurns ?? 20); + const systemPrompt = opts.promptSwitches + ? composeInstructions(kind, opts.promptSwitches) + : composedInstructions[kind]!; const agent = new Agent({ initialState: { - systemPrompt: composedInstructions[kind], + systemPrompt, model: engine.model, thinkingLevel: thinkingLevelFor(kind), tools, diff --git a/src/core/index.ts b/src/core/index.ts index b7d7cdf..6796a9e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -29,6 +29,7 @@ export * from "./layout.ts"; export * from "./plan-tree.ts"; export * from "./phases.ts"; export * from "./pending.ts"; +export * from "./prompt-segments.ts"; export * from "./math-delims.ts"; export * from "./ui-message.ts"; export * from "./tool-parts.ts"; diff --git a/src/core/prompt-segments.ts b/src/core/prompt-segments.ts new file mode 100644 index 0000000..cf7444e --- /dev/null +++ b/src/core/prompt-segments.ts @@ -0,0 +1,47 @@ +/** + * The pure prompt-segment grammar (P2 reconciliation, 2026-09-19): + * segment-path validation + the shared segment types, ZERO fs. Discovery + * (reading the agent's prompt tree) lives in the agent layer + * (src/agent/prompt-segments.ts — the canonical loader composeInstructions + * composes through); the override-file CRUD lives in the store adapter + * (src/store/prompt-overrides.ts). Both import THIS grammar so the + * mirror-layout rules are defined exactly once. + * + * Path grammar: "system-prompt.md" (the shared base), "shared/" + * (sources v1: fragments composed by BOTH agents), "prober/" / + * "teacher/" — where is numeric-prefixed + .md + * (e.g. 20-teaching.md). POSIX-relative, no traversal, no nesting. + */ + +/** A prompt fragment: numeric order key + .md (e.g. 20-teaching.md). Nothing else loads. */ +export const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/; + +/** Which agent's prompt directory to compose. */ +export type PromptAgent = "prober" | "teacher"; + +/** One segment of one agent's composed prompt. */ +export interface PromptSegmentInfo { + /** The override/store key — mirrors the source layout ("system-prompt.md", + * "shared/05-learner-files.md", "teacher/20-teaching.md"). Stable across + * agents: the base and shared/ fragments use the SAME path in both + * agents' lists. */ + path: string; + /** The source text (what the segment runs without an override). */ + sourceText: string; + /** The user's override text, or null when no file exists on disk. */ + overrideText: string | null; + /** Effective state: the settings switch AND the file both present. */ + enabled: boolean; + /** True for segments BOTH agents compose — the base and shared/ fragments. */ + shared: boolean; +} + +/** Validate a segment path against the mirror-layout rules (see module + * doc). Exported for the routes, which also check membership in the + * CURRENT source segment list. */ +export function isValidSegmentPath(segmentPath: string): boolean { + if (segmentPath === "system-prompt.md") return true; + const m = /^(prober|teacher|shared)\/([\w.-]+\.md)$/.exec(segmentPath); + if (!m || m[2] === undefined) return false; + return FRAGMENT_RE.test(m[2]); +} diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index f80b42d..69e2414 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -222,6 +222,10 @@ export async function handleChatRequest( messages: recordToPiMessages(outgoing), onUsage: collectUsage, toolToggles: effective.agent.tools, + // The prompt-override switches (P2, 2026-09-19): same resolution + // point as the tool delta — the agent layer composes the system + // prompt fresh per turn, swapping in any enabled override segments. + promptSwitches: effective.agent.prompts, }; const constructed: TeachingAgent = buildAgent ? buildAgent(outgoing) diff --git a/src/server/index.ts b/src/server/index.ts index 48366d1..0ffac57 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,12 +20,12 @@ export { writeSettings as writeSettingsRecord, settingsStore, } from "../store/settings.ts"; -export { - promptOverrides, - effectivePromptSegments, - allSegmentPaths, - isValidSegmentPath, -} from "../store/prompt-overrides.ts"; +export { promptOverrides } from "../store/prompt-overrides.ts"; +export { isValidSegmentPath } from "../core/prompt-segments.ts"; +// The canonical segment discovery/resolution lives in the agent layer +// (composition is an agent concern); the barrel re-exports it for the +// routes — server→agent is the lawful direction (chat-handler precedent). +export { effectivePromptSegments, allSegmentPaths, readPromptOverride } from "../agent/prompt-segments.ts"; export * from "./settings-body.ts"; export * from "./turn-liveness.ts"; export * from "./observability.ts"; diff --git a/src/store/prompt-overrides.ts b/src/store/prompt-overrides.ts index e619d97..45b6e34 100644 --- a/src/store/prompt-overrides.ts +++ b/src/store/prompt-overrides.ts @@ -1,27 +1,27 @@ /** - * Per-segment system-prompt overrides (settings-storage wave, 2026-09-19) - * — the storage + the SOURCE-DERIVED segment list. + * The prompt-override STORE (settings-storage wave, 2026-09-19; P2 + * reconciliation same day): the override-file CRUD behind the core port — + * list/read/write/delete under config.PROMPTS_OVERRIDE_DIR. * - * SEGMENTS: a prompt composes from source at runtime exactly as - * teacher-agent.ts's composeInstructions does — the SHARED base - * (src/agent/system-prompt.md) plus every numeric-prefix *.md fragment in - * the agent's directory (src/agent/prompts/prober/ or teacher/), sorted by - * filename. The user may override ANY segment individually (the shared - * base in one edit changes both agents). This module re-implements the - * discovery rules (FRAGMENT_RE + sort) deliberately rather than importing - * src/agent (parallel-stream lane discipline); the Teacher stream is free - * to refactor composeInstructions onto listSourceSegments later — the - * definitions here are the canonical mirror until then. + * The CANONICAL segment discovery + resolution (listSourceSegments, + * effectivePromptSegments, allSegmentPaths) lives in the agent layer + * (src/agent/prompt-segments.ts) — composition is an agent concern, and + * the architecture law pins the layers apart (store may not import agent; + * agent may not import store). Both sides share the pure path grammar + * (src/core/prompt-segments.ts); the store's readOverride and the agent's + * readPromptOverride read the SAME mirror layout. + * + * SEGMENTS: a prompt composes from the SHARED base + * (src/agent/system-prompt.md), the shared/ fragments (composed by both + * agents), plus every numeric-prefix *.md fragment in the agent's own + * directory — see the agent module for the full rules. * * OVERRIDES: override files mirror the source layout under * config.PROMPTS_OVERRIDE_DIR — "system-prompt.md" at the root, - * "teacher/" / "prober/" for fragments (POSIX-relative paths, - * the same keys as the settings record's agent.prompts switches). File - * presence is NOT the override: the switch is (owner decision) — the user - * keeps their file and flips back to the source default to compare. An - * override keyed to a segment the SOURCE no longer ships (fragment removed - * or renamed upstream) stops composing automatically — the segment list - * adapts to the code, never the reverse; the orphaned file stays on disk. + * "shared/", "teacher/" / "prober/" (POSIX-relative + * paths, the same keys as the settings record's agent.prompts switches). + * File presence is NOT the override: the switch is (owner decision) — the + * user keeps their file and flips back to the source default to compare. * * Path discipline: only .md under the override dir, no traversal (no "..", * absolute, or empty segments) — a hand-crafted path outside the mirror @@ -30,40 +30,10 @@ import fs from "node:fs"; import path from "node:path"; -import { PATHS, PROMPTS_OVERRIDE_DIR } from "../../config.ts"; +import { PROMPTS_OVERRIDE_DIR } from "../../config.ts"; +import { isValidSegmentPath } from "../core/prompt-segments.ts"; import type { PromptOverrideStore } from "../core/ports.ts"; -/** Same fragment rule as composeInstructions: numeric order prefix + .md. */ -const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/; - -export type PromptAgent = "prober" | "teacher"; - -/** One segment of one agent's composed prompt. */ -export interface PromptSegmentInfo { - /** The override/store key — mirrors the source layout ("system-prompt.md", - * "teacher/20-teaching.md"). Stable across agents: the shared base uses - * the SAME path in both agents' lists. */ - path: string; - /** The source text (what the segment runs without an override). */ - sourceText: string; - /** The user's override text, or null when no file exists on disk. */ - overrideText: string | null; - /** Effective state: the settings switch AND the file both present. */ - enabled: boolean; - /** True for the shared base — the segment both agents compose. */ - shared: boolean; -} - -/** Validate a segment path against the mirror-layout rules (see module - * doc). Exported for the routes, which also check membership in the - * CURRENT source segment list. */ -export function isValidSegmentPath(segmentPath: string): boolean { - if (segmentPath === "system-prompt.md") return true; - const m = /^(prober|teacher)\/([\w.-]+\.md)$/.exec(segmentPath); - if (!m || m[2] === undefined) return false; - return FRAGMENT_RE.test(m[2]); -} - /** Absolute path for an override file (unchecked — validate first). */ function overrideFileFor(segmentPath: string): string { return path.join(PROMPTS_OVERRIDE_DIR, ...segmentPath.split("/")); @@ -115,60 +85,3 @@ export const promptOverrides: PromptOverrideStore = { } }, }; - -/** List the SOURCE segments for one agent, in composition order — the - * shared base first, then the agent's fragments sorted by filename. - * Adapts automatically when the code ships/renames/removes fragments. */ -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 dir = path.join(PATHS.root, "src", "agent", "prompts", 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, -): 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 = promptOverrides.readOverride(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(); - for (const agent of ["prober", "teacher"] as const) { - for (const segment of listSourceSegments(agent)) paths.add(segment.path); - } - return [...paths].sort(); -} diff --git a/tests/prompt-overrides.test.ts b/tests/prompt-overrides.test.ts index 2f7d8b4..6d6dac0 100644 --- a/tests/prompt-overrides.test.ts +++ b/tests/prompt-overrides.test.ts @@ -12,12 +12,10 @@ import fs from "node:fs"; import path from "node:path"; import { after, describe, it } from "node:test"; import { PROMPTS_OVERRIDE_DIR } from "../config.ts"; -import { - allSegmentPaths, - effectivePromptSegments, - isValidSegmentPath, - promptOverrides, -} from "../src/store/prompt-overrides.ts"; +import * as teacherAgent from "../src/agent/teacher-agent.ts"; +import { allSegmentPaths, effectivePromptSegments } from "../src/agent/prompt-segments.ts"; +import { isValidSegmentPath } from "../src/core/prompt-segments.ts"; +import { promptOverrides } from "../src/store/prompt-overrides.ts"; after(() => { fs.rmSync(PROMPTS_OVERRIDE_DIR, { recursive: true, force: true }); @@ -28,6 +26,9 @@ describe("isValidSegmentPath (the mirror-layout discipline)", () => { assert.equal(isValidSegmentPath("system-prompt.md"), true); assert.equal(isValidSegmentPath("teacher/20-teaching.md"), true); assert.equal(isValidSegmentPath("prober/10-probe-doctrine.md"), true); + // the shared/ fragments dir (sources v1): composed by BOTH agents, + // overridable like any other segment + assert.equal(isValidSegmentPath("shared/05-learner-files.md"), true); for (const bad of [ "", // empty "README.md", // no agent dir @@ -87,14 +88,30 @@ describe("the source-derived segment list", () => { assert.equal(prober[0]?.shared, true); assert.equal(teacher[0]?.shared, true); assert.equal(prober[0]?.sourceText.length > 0, true, "base prompt text loads"); - // Fragments track the REAL source tree (numeric order, right agent dir). + // Fragments track the REAL source tree (numeric order, right agent dir), + // with the shared/ fragments between the base and the agent's own — + // the exact order composeInstructions loads them in (P2, 2026-09-19). assert.deepEqual( prober.map((s) => s.path), - ["system-prompt.md", "prober/10-probe-doctrine.md", "prober/20-teaching-in-brief.md", "prober/30-probe-done.md"], + [ + "system-prompt.md", + "shared/05-learner-files.md", + "prober/10-probe-doctrine.md", + "prober/20-teaching-in-brief.md", + "prober/30-probe-done.md", + ], ); assert.equal(teacher.some((s) => s.path === "teacher/10-planning.md"), true); assert.equal(teacher.some((s) => s.path === "teacher/50-probing-in-brief.md"), true); - assert.equal(teacher.every((s) => s.path === "system-prompt.md" || s.path.startsWith("teacher/")), true); + assert.equal( + teacher.every( + (s) => s.path === "system-prompt.md" || s.path.startsWith("teacher/") || s.path.startsWith("shared/"), + ), + true, + ); + // the shared fragments compose for BOTH agents, same path key. + assert.equal(teacher.some((s) => s.path === "shared/05-learner-files.md" && s.shared), true); + assert.equal(prober.some((s) => s.path === "shared/05-learner-files.md" && s.shared), true); // README and other non-numeric files never load. assert.equal(allSegmentPaths().some((p) => p.includes("README")), false); }); @@ -127,3 +144,78 @@ describe("the source-derived segment list", () => { assert.equal(before > 5, true, "base + prober fragments + teacher fragments"); }); }); + +// ─── composeInstructions honors overrides (P2 reconciliation, 2026-09-19) ──── +// This file OWNS PROMPTS_OVERRIDE_DIR (parallel-test discipline), so every +// override-writing composition test lives here. + +describe("composeInstructions: override honoring + the byte-identical source path", () => { + const { composeInstructions, createTeacherAgent } = teacherAgent; + + it("switches-less composition is byte-identical to the pre-refactor loader", () => { + // The old loader, rebuilt inline: base + shared/ fragments + the + // agent's own, trimmed, empties dropped, joined with the separator. + const oldLoader = (kind: "prober" | "teacher") => { + const fragmentsOf = (dir: string) => + fs + .readdirSync(dir) + .filter((f) => /^\d+-[\w.-]+\.md$/.test(f)) + .sort() + .map((f) => fs.readFileSync(path.join(dir, f), "utf-8").trim()) + .filter(Boolean); + const base = fs.readFileSync("src/agent/system-prompt.md", "utf-8").trim(); + return [ + base, + ...fragmentsOf("src/agent/prompts/shared"), + ...fragmentsOf(`src/agent/prompts/${kind}`), + ].join("\n\n---\n\n"); + }; + assert.equal(composeInstructions("teacher"), oldLoader("teacher")); + assert.equal(composeInstructions("prober"), oldLoader("prober")); + }); + + it("an enabled override swaps in; switch off or file absent composes source", () => { + promptOverrides.writeOverride("teacher/20-teaching.md", "CUSTOM TEACHING LOOP"); + try { + const on = composeInstructions("teacher", { "teacher/20-teaching.md": true }); + assert.ok(on.includes("CUSTOM TEACHING LOOP"), "override composes when switch on"); + assert.ok(!on.includes("# Teach — the loop"), "source text is replaced"); + // switch off → source default (the file stays on disk) + const off = composeInstructions("teacher", {}); + assert.ok(off.includes("# Teach — the loop"), "source composes when switch off"); + // switch on for a segment with NO file → source (enabled needs both) + const noFile = composeInstructions("teacher", { "teacher/10-planning.md": true }); + assert.ok(noFile.includes("# Planning"), "file absent → source default"); + } finally { + promptOverrides.deleteOverride("teacher/20-teaching.md"); + } + }); + + it("shared/ fragments are overridable — one edit swaps the doctrine in BOTH agents", () => { + promptOverrides.writeOverride("shared/05-learner-files.md", "MY SHARED FILES DOCTRINE"); + try { + const switches = { "shared/05-learner-files.md": true }; + for (const kind of ["teacher", "prober"] as const) { + const composed = composeInstructions(kind, switches); + assert.ok(composed.includes("MY SHARED FILES DOCTRINE"), `${kind} composes the override`); + assert.ok(!composed.includes("## Learner files"), `${kind} source fragment is replaced`); + } + // switches-less: both keep the source fragment (the cached path) + assert.ok(composeInstructions("teacher").includes("## Learner files")); + } finally { + promptOverrides.deleteOverride("shared/05-learner-files.md"); + } + }); + + it("promptSwitches threads through agent construction into the system prompt", () => { + promptOverrides.writeOverride("system-prompt.md", "TOTALLY CUSTOM BASE"); + try { + const agent = createTeacherAgent(undefined, { promptSwitches: { "system-prompt.md": true } }); + assert.ok(agent.state.systemPrompt.includes("TOTALLY CUSTOM BASE")); + const source = createTeacherAgent(undefined, {}); + assert.ok(!source.state.systemPrompt.includes("TOTALLY CUSTOM BASE"), "no switches → cached source"); + } finally { + promptOverrides.deleteOverride("system-prompt.md"); + } + }); +});