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:
amosblomqvist
2026-09-19 18:21:09 +03:00
parent 6c42393e42
commit 524bf6024a
8 changed files with 333 additions and 147 deletions
+21 -108
View File
@@ -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();
}