Files
learnharness/src/agent/teacher-agent.ts
T
amosblomqvist 07b25c4ffd de-meta the system prompts; method leads the composition (owner, 2026-09-27)
The owner's capture-vs-composition check confirmed the wire sends exactly
what composes (byte-identical save the nodeId paragraph ebfe058 had since
changed). This wave removes the implementation meta that leaked into the
prompts and reorders so the method carries weight:

- base: owner's new opener verbatim (install understanding; the
  understanding definition — which absorbs the old 'Understanding is
  structure' paragraph); Voice moved in from shared/10 (deleted); the
  philosophy keeps edge-dependence + logistics ('standard learning'
  dropped per owner); intro's two-agents/fragments sentence gone.
- teacher/05-method opens with the owner's methodology bridge ('No other
  teaching methods come close') — paragraph 3 of the opener, placed here
  so the shared base never points the prober at methodology it doesn't
  carry.
- deleted: teacher/60-probing-in-brief (self-evident from shared history;
  its mid-lesson mini-probe move salvaged into 20-teaching as one
  sentence), the 'Session shape — the durable artifact' section
  (extraction-pipeline meta), the fragments-meta lines in shared/20 and
  05-method, 'the transcript's seam' phrasing, prober's 'the teacher
  that takes over' / 'the handoff is automatic'.
- composition reorder (listSourceSegments): base -> the agent's own
  fragments -> shared/ appendix. Teacher method now starts at 12% in
  (was 62%); prober doctrine at 34%. shared/20 opens with the owner's
  decision rule (quiz only for known-correct-answer, ask for everything
  else).
- HANDOFF_TEXT: the owner's user-message version — first-person, paces
  the planning (unconditional truths, motivated derivations), ends at
  plan_init + accept-or-changes.

Teacher 31.4k -> 28.7k chars; prober 10.0k -> 9.6k. Gates: typecheck 0,
1018/1018, lint clean, build green. (Suite runs 16s; the earlier 10-min
timeout was stale node_modules after master's df1328e 0.87.1 sync —
npm install fixed it.)
2026-09-27 04:14:42 +03:00

264 lines
12 KiB
TypeScript

import { Agent } from "@earendil-works/pi-agent-core";
import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
import { questionResult, ToolInputError } from "./tool-execute.ts";
import type { Usage } from "@earendil-works/pi-ai";
import { PATHS } from "../../config.ts";
import { piEngineFor, type PiEngine } from "./models.ts";
import { effectivePromptSegments } from "./prompt-segments.ts";
import {
askUserQuestionTool,
createProberTools,
createTeacherTools,
type ProbeDoneContext,
type ProberToolContext,
type TeacherToolContext,
} from "./tools.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages } from "../core/native-messages.ts";
/**
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
* the TEACHER. Same transcript, different prompts and tool sets; the
* chat-handler constructs ONE per request off the transcript's seam
* (probe_done = teacher) — the STATELESS per-turn engine (docs/
* pi-engine-swap.md §2): the persisted record is the source of truth, the
* agent is rebuilt from it every POST and discarded at turn end.
*
* Engines are injectable so tests can pass a scripted faux engine and drive
* deterministic lessons (see tests/chat-flow.test.ts).
*
* System prompts are COMPOSED per agent: system-prompt.md (the shared base:
* agent, voice, philosophy, accuracy) + every numeric-prefix fragment in
* that agent's directory (src/agent/prompts/prober/ or
* src/agent/prompts/teacher/), in sorted filename order, then the shared/
* fragments as a trailing appendix (question tools, learner files,
* formatting) — the 2026-09-27 reorder: the agent's job and method lead,
* the mechanics trail. Joined with `---` separators.
*
* ONLY numeric-prefix files load — the loader enforces the convention, so
* non-fragment documentation (README.md, …) can live in the directories
* without ever leaking into the model's prompt.
*/
/** Which agent's prompt directory to compose. */
export type AgentKind = "prober" | "teacher";
// 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.
*
* 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"),
};
// Money channel (wave 4): the chat-handler passes a per-turn collector;
// every model call's usage lands in the chat record's turns log
// (ChatStore.appendTurnUsage). The narrow param keeps the seam honest.
export type StepUsageCollector = (usage: Usage) => void;
/** Per-request construction options (the handler wires all of these). */
export interface AgentBuildOptions {
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
/** The initial transcript, as pi messages (the record IS agent state). */
messages?: AgentMessage[];
/** Money channel: one call per model request. */
onUsage?: StepUsageCollector;
/** Tests may pass a bare tool registry to drive deterministic flows.
* An explicit registry is NEVER toggle-filtered — the caller owns it. */
tools?: Record<string, AgentTool<any>>;
/** The resolved per-user tool toggles (core/pending EnabledTools — the
* 2026-09-19 P2 bridge: Platform's chat-handler passes
* effective.agent.tools; the factories drop any tool explicitly false.
* 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>;
}
/**
* The turn's question-stop rule (docs/pi-engine-swap.md §2; Wave A
* 2026-09-26, owner Option A): a question tool call (quiz /
* ask_user_question) that RESOLVED — its synthetic "question posed" result
* is not an error — ends the run: the learner's answer arrives as the next
* POST. A call that FAILED authoring validation (error result) must NOT
* stop the run — the model retries within the same turn, exactly the old
* InvalidToolInputError semantics.
*
* Question results also carry terminate: true (tool-execute's
* questionResult), so single-question batches stop natively at the pi
* loop's own batch check; this finishTurn hook (0.87.1 rename of
* shouldStopAfterTurn) is the
* belt-and-suspenders HALF for the mixed batch (a question + other calls
* in one message) that terminate-only semantics would let run on — the
* learner cannot answer while a turn is in flight (per-chat 409), so a
* cleanly-posed question MUST always end the turn. maxTurns is gone
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
* turn).
*/
function makeQuestionStopper() {
return (context: {
message: { content: Array<{ type: string; id?: string; name?: string }> };
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
}): boolean => {
const called = new Set(
context.message.content
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
.map((c) => c.id!),
);
if (called.size === 0) return false;
// Stop only when every question call of this turn resolved cleanly.
for (const result of context.toolResults) {
if (called.has(result.toolCallId) && result.isError) return false;
}
return true;
};
}
function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool<any, any>[]) {
const engine = opts.engine ?? piEngineFor(kind);
const stopper = makeQuestionStopper();
const systemPrompt = opts.promptSwitches
? composeInstructions(kind, opts.promptSwitches)
: composedInstructions[kind]!;
const agent = new Agent({
initialState: {
systemPrompt,
model: engine.model,
thinkingLevel: engine.level,
tools,
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
// 0.87.1: shouldStopAfterTurn → finishTurn. Same call point (after
// tool results attach, before turn_end) and `{ action: "end" }` still
// exits BEFORE the steering poll — the parked-marker stranding our
// Wave C registerLiveAgent-before-runPiTurn design depends on.
// undefined preserves normal scheduling (the old boolean false).
finishTurn: (turn) => (stopper(turn) ? { action: "end" } : undefined),
// Wave C (2026-09-26, owner correction same day): markers handed to a
// live run drain from pi's STEERING queue — the lesson loop is one
// continuous teach→quiz→teach run, so delivery must inject at turn
// boundaries INSIDE the run, not wait for it to stop (follow-ups).
// "all" joins every handed marker at one boundary, matching the idle
// wake's carry-everything semantics (the default "one-at-a-time"
// would spend one teacher turn per marker).
steeringMode: "all",
// Wave B (owner-approved 2026-09-26): the model-facing conversion is
// THE app seam — markers flatten to their canonical user text
// (HANDOFF_TEXT / steer text, all single-sourced),
// UI-only markers (plan-block) drop, the app's id fields strip, and
// an abort's dangling question call gets its INTERRUPTED result. It
// must never throw (pi's convertToLlm contract).
convertToLlm: toLlmMessages,
});
if (opts.onUsage) {
agent.subscribe((event: AgentEvent) => {
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
// A cancelled call is not a billed step (partial/estimated
// usage on an aborted message is not money spent).
if (event.message.stopReason === "aborted") return;
opts.onUsage!(event.message.usage);
}
});
}
return agent;
}
/**
* The PROBER (agent A): quiz (no nodeId) + ask + probe_done, lean on
* probing doctrine. Its whole job is bracketing the learner's edge and
* concluding with the assessment (probe_done) — which also queues the
* handoff wake (see tools/probe.ts) and is the transcript seam the
* handler keys teacher construction on.
*/
export function createProberAgent(ctx: ProberToolContext, opts: AgentBuildOptions = {}) {
return baseAgentOptions("prober", opts, Object.values(createProberTools(ctx, opts.toolToggles)));
}
/**
* The TEACHER (agent B): enters at planning (the probe is already
* concluded — its assessment is in the transcript), owns plan_init and the
* full toolset (teach questions with validated nodeId, the map, research,
* demos).
*/
export function createTeacherAgent(
// Optional so tests can construct a bare mock-driven agent (no storage
// wiring): without ctx the tools fall back to the validation-free
// question set. The handler ALWAYS passes ctx (the composition root).
ctx?: TeacherToolContext,
opts: AgentBuildOptions = {},
) {
const tools = opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools());
return baseAgentOptions("teacher", opts, Object.values(tools));
}
/** The bare teach question set (tests, bare agents): quiz with the shared
* authoring refinement (quizInputProblems, run in execute — throws
* ToolInputError, which pi's loop converts to the error tool result the
* model retries on) but NO per-session nodeId check (no record to read),
* plus ask. */
export function bareQuestionTools(): Record<string, AgentTool<any>> {
// A typed const (not an inline literal): the specific AgentTool typing
// gives execute its precise Static input; heterogeneous records take
// AgentTool<any>, whose loose Static would smudge it to unknown.
const quiz: AgentTool<typeof quizInputObject> = {
name: "quiz",
label: "quiz",
description: "Ask the learner a graded question.",
parameters: quizInputObject,
executionMode: "sequential",
execute: async (_toolCallId, input) => {
const problems = quizInputProblems(input);
if (problems.length > 0) throw new ToolInputError("quiz", problems);
return questionResult();
},
};
return {
quiz,
ask_user_question: askUserQuestionTool,
};
}
export type ProberAgent = ReturnType<typeof createProberAgent>;
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
export type TeachingAgent = ProberAgent | TeacherAgent;