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();
|
||||
}
|
||||
+40
-24
@@ -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, boolean>): 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<AgentKind, string> = {
|
||||
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<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,9 +146,12 @@ function makeStopper(maxTurns: number) {
|
||||
function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool<any, any>[]) {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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/<fragment>"
|
||||
* (sources v1: fragments composed by BOTH agents), "prober/<fragment>" /
|
||||
* "teacher/<fragment>" — where <fragment> 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]);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+6
-6
@@ -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";
|
||||
|
||||
+21
-108
@@ -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/<file>" / "prober/<file>" 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/<file>", "teacher/<file>" / "prober/<file>" (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<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 = 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<string>();
|
||||
for (const agent of ["prober", "teacher"] as const) {
|
||||
for (const segment of listSourceSegments(agent)) paths.add(segment.path);
|
||||
}
|
||||
return [...paths].sort();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user