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
+113
View File
@@ -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
View File
@@ -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,