2026-08-22 02:18:27 +03:00
|
|
|
import * as fs from "node:fs";
|
2026-08-22 09:06:24 +03:00
|
|
|
import * as path from "node:path";
|
2026-09-11 06:44:20 +03:00
|
|
|
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type LanguageModelUsage, type ToolSet } from "ai";
|
2026-08-22 02:18:27 +03:00
|
|
|
import { PATHS } from "../../config.ts";
|
2026-09-01 01:47:47 +03:00
|
|
|
import { modelFor } from "./models.ts";
|
2026-09-11 06:44:20 +03:00
|
|
|
import {
|
|
|
|
|
askUserQuestionTool,
|
|
|
|
|
createProberTools,
|
|
|
|
|
createTeacherTools,
|
|
|
|
|
type ProbeDoneContext,
|
|
|
|
|
type TeacherToolContext,
|
|
|
|
|
} from "./tools.ts";
|
|
|
|
|
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
2026-08-22 02:18:27 +03:00
|
|
|
|
|
|
|
|
/**
|
2026-09-11 06:44:20 +03:00
|
|
|
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
|
|
|
|
* the TEACHER. Same harness (agent loop, tool calling, streaming,
|
|
|
|
|
* multi-step — the few lines ToolLoopAgent provides), same transcript, but
|
|
|
|
|
* different prompts and different tool sets; the chat-handler constructs
|
|
|
|
|
* one per request off the transcript's seam (probe_done = teacher).
|
2026-08-22 02:18:27 +03:00
|
|
|
*
|
2026-09-11 06:44:20 +03:00
|
|
|
* Models are injectable so tests can pass a MockLanguageModelV4 and drive
|
2026-08-22 02:18:27 +03:00
|
|
|
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
2026-08-22 09:06:24 +03:00
|
|
|
*
|
2026-09-11 06:44:20 +03:00
|
|
|
* System prompts are COMPOSED per agent: system-prompt.md (the shared base:
|
|
|
|
|
* philosophy, accuracy, quiz construction, question tools, formatting) +
|
|
|
|
|
* every numeric-prefix fragment in that agent's directory
|
|
|
|
|
* (src/agent/prompts/prober/ or src/agent/prompts/teacher/), in sorted
|
|
|
|
|
* filename order, joined with `---` separators.
|
2026-09-02 03:19:03 +03:00
|
|
|
*
|
|
|
|
|
* ONLY numeric-prefix files load — the loader enforces the convention, so
|
2026-09-11 06:44:20 +03:00
|
|
|
* non-fragment documentation (README.md, …) can live in the directories
|
2026-09-02 03:19:03 +03:00
|
|
|
* without ever leaking into the model's prompt.
|
2026-08-22 02:18:27 +03:00
|
|
|
*/
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
/** Which agent's prompt directory to compose. */
|
|
|
|
|
export type AgentKind = "prober" | "teacher";
|
|
|
|
|
|
2026-08-22 09:06:24 +03:00
|
|
|
const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts");
|
2026-08-22 02:18:27 +03:00
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
/** A prompt fragment: numeric order key + .md (e.g. 20-teaching.md). Nothing else loads. */
|
2026-09-02 03:19:03 +03:00
|
|
|
const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/;
|
|
|
|
|
|
|
|
|
|
/** Exported for tests: assert what composes into the model's prompt. */
|
2026-09-11 06:44:20 +03:00
|
|
|
export function composeInstructions(kind: AgentKind): string {
|
2026-08-22 09:06:24 +03:00
|
|
|
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
2026-09-11 06:44:20 +03:00
|
|
|
const dir = path.join(PROMPTS_DIR, kind);
|
|
|
|
|
if (!fs.existsSync(dir)) return base;
|
2026-08-22 09:06:24 +03:00
|
|
|
const fragments = fs
|
2026-09-11 06:44:20 +03:00
|
|
|
.readdirSync(dir)
|
2026-09-02 03:19:03 +03:00
|
|
|
.filter((f) => FRAGMENT_RE.test(f))
|
2026-08-22 09:06:24 +03:00
|
|
|
.sort()
|
2026-09-11 06:44:20 +03:00
|
|
|
.map((f) => fs.readFileSync(path.join(dir, f), "utf-8").trim())
|
2026-08-22 09:06:24 +03:00
|
|
|
.filter(Boolean);
|
|
|
|
|
return [base.trim(), ...fragments].join("\n\n---\n\n");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
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 step carries far more than usage —
|
|
|
|
|
// the narrow param keeps the seam honest about what we consume.
|
|
|
|
|
export type StepUsageCollector = (step: { usage: LanguageModelUsage }) => void;
|
2026-08-22 09:06:24 +03:00
|
|
|
|
2026-08-22 11:41:50 +03:00
|
|
|
/**
|
2026-09-11 06:44:20 +03:00
|
|
|
* 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.
|
2026-08-22 11:41:50 +03:00
|
|
|
*/
|
2026-09-11 06:44:20 +03:00
|
|
|
export function createProberAgent(ctx: ProbeDoneContext, model?: LanguageModel, onStepFinish?: StepUsageCollector) {
|
|
|
|
|
return new ToolLoopAgent({
|
|
|
|
|
// Model choice + thinking budget live in config.ts (MODELS.prober).
|
|
|
|
|
// Reasoning tokens stream as reasoning parts into the UI's collapsible block.
|
|
|
|
|
model: model ?? modelFor("prober"),
|
|
|
|
|
instructions: composedInstructions.prober,
|
|
|
|
|
tools: createProberTools(ctx),
|
|
|
|
|
// Question tools are client-side (a call ends the server turn);
|
|
|
|
|
// probe_done resolves immediately (it queues the handoff and acks).
|
|
|
|
|
// stepCountIs is just the safety net.
|
|
|
|
|
stopWhen: stepCountIs(20),
|
|
|
|
|
...(onStepFinish ? { onStepFinish } : {}),
|
|
|
|
|
});
|
2026-08-22 11:41:50 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
/**
|
|
|
|
|
* 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).
|
|
|
|
|
*/
|
2026-09-11 04:07:12 +03:00
|
|
|
export function createTeacherAgent(
|
2026-09-11 06:44:20 +03:00
|
|
|
// 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,
|
2026-09-11 04:07:12 +03:00
|
|
|
model?: LanguageModel,
|
2026-09-11 06:44:20 +03:00
|
|
|
onStepFinish?: StepUsageCollector,
|
|
|
|
|
// Tests may pass a bare tools set to drive deterministic flows; when
|
|
|
|
|
// present it overrides the ctx-composed registry.
|
2026-09-11 04:07:12 +03:00
|
|
|
tools?: ToolSet,
|
|
|
|
|
) {
|
2026-08-22 02:18:27 +03:00
|
|
|
return new ToolLoopAgent({
|
2026-09-01 01:47:47 +03:00
|
|
|
// Model choice + thinking budget live in config.ts (MODELS.teacher).
|
|
|
|
|
model: model ?? modelFor("teacher"),
|
2026-09-11 06:44:20 +03:00
|
|
|
instructions: composedInstructions.teacher,
|
|
|
|
|
// Tools are per-request (they close over the injected ctx — chat-
|
|
|
|
|
// handler passes the composition root's stores + spawn seam).
|
|
|
|
|
tools: tools ?? (ctx ? createTeacherTools(ctx) : bareQuestionTools()),
|
2026-09-01 19:15:43 +03:00
|
|
|
// Question tools are client-side (a call ends the server turn);
|
|
|
|
|
// job tools resolve immediately (fire-and-forget ack). stepCountIs is
|
|
|
|
|
// just the safety net.
|
2026-08-22 02:18:27 +03:00
|
|
|
stopWhen: stepCountIs(20),
|
2026-09-11 04:07:12 +03:00
|
|
|
...(onStepFinish ? { onStepFinish } : {}),
|
2026-08-22 02:18:27 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
/** The validation-free teach question set (tests, bare agents): quiz with
|
|
|
|
|
* the shared authoring refinement but NO per-session nodeId check (no
|
|
|
|
|
* record to read), plus ask. */
|
|
|
|
|
export function bareQuestionTools(): ToolSet {
|
|
|
|
|
return {
|
|
|
|
|
quiz: tool({
|
|
|
|
|
description: "Ask the learner a graded question.",
|
|
|
|
|
inputSchema: quizInputObject.superRefine((input, add) => {
|
|
|
|
|
for (const problem of quizInputProblems(input)) {
|
|
|
|
|
add.addIssue({ code: "custom", message: problem });
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
ask_user_question: askUserQuestionTool,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type ProberAgent = ReturnType<typeof createProberAgent>;
|
2026-08-22 02:18:27 +03:00
|
|
|
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|
2026-09-11 06:44:20 +03:00
|
|
|
export type TeachingAgent = ProberAgent | TeacherAgent;
|