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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user