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 04:07:12 +03:00
|
|
|
import { ToolLoopAgent, stepCountIs, 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-01 19:15:43 +03:00
|
|
|
import { questionTools } from "./tools.ts";
|
2026-08-22 02:18:27 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The teacher agent. Everything pi used to provide (agent loop, tool calling,
|
|
|
|
|
* streaming, multi-step) is these few lines now.
|
|
|
|
|
*
|
|
|
|
|
* The model is injectable so tests can pass a MockLanguageModelV4 and drive
|
|
|
|
|
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
2026-08-22 09:06:24 +03:00
|
|
|
*
|
2026-08-22 11:41:50 +03:00
|
|
|
* The optional graphContext (buildGraphContext() in src/core/graph-context.ts,
|
|
|
|
|
* wired by the chat-handler per request) is appended after the composed
|
|
|
|
|
* fragments; absent/empty means a new learner — nothing is injected.
|
|
|
|
|
*
|
2026-08-22 09:06:24 +03:00
|
|
|
* The system prompt is COMPOSED: system-prompt.md is the base, then every
|
|
|
|
|
* src/agent/prompts/*.md fragment in sorted filename order (workers add
|
|
|
|
|
* fragments — nobody edits shared sections of the base prompt). Prefix
|
|
|
|
|
* fragments with a numeric order key: 10-plan-tree.md, 20-jobs.md, …
|
2026-09-02 03:19:03 +03:00
|
|
|
*
|
|
|
|
|
* ONLY numeric-prefix files load — the loader enforces the convention, so
|
|
|
|
|
* non-fragment documentation (README.md, …) can live in the directory
|
|
|
|
|
* without ever leaking into the model's prompt.
|
2026-08-22 02:18:27 +03:00
|
|
|
*/
|
|
|
|
|
|
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-02 03:19:03 +03:00
|
|
|
/** A prompt fragment: numeric order key + .md (e.g. 20-jobs.md). Nothing else loads. */
|
|
|
|
|
const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/;
|
|
|
|
|
|
|
|
|
|
/** Exported for tests: assert what composes into the model's prompt. */
|
|
|
|
|
export function composeInstructions(): string {
|
2026-08-22 09:06:24 +03:00
|
|
|
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
|
|
|
|
if (!fs.existsSync(PROMPTS_DIR)) return base;
|
|
|
|
|
const fragments = fs
|
|
|
|
|
.readdirSync(PROMPTS_DIR)
|
2026-09-02 03:19:03 +03:00
|
|
|
.filter((f) => FRAGMENT_RE.test(f))
|
2026-08-22 09:06:24 +03:00
|
|
|
.sort()
|
|
|
|
|
.map((f) => fs.readFileSync(path.join(PROMPTS_DIR, f), "utf-8").trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
return [base.trim(), ...fragments].join("\n\n---\n\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const instructions = composeInstructions();
|
|
|
|
|
|
2026-08-22 11:41:50 +03:00
|
|
|
/**
|
|
|
|
|
* Compose the full instruction string: base prompt + fragments, then — when
|
|
|
|
|
* the session carries graph context (W8, vision §1 "the graph as session
|
|
|
|
|
* context") — the auto-generated knowledge-graph block appended after them as
|
|
|
|
|
* a clearly-delimited section (the 50-graph-context.md fragment teaches the
|
|
|
|
|
* teacher how to read it). The fragment mechanism itself stays untouched.
|
|
|
|
|
*/
|
|
|
|
|
function withGraphContext(graphContext: string | null | undefined): string {
|
|
|
|
|
if (!graphContext) return instructions;
|
|
|
|
|
return `${instructions}\n\n---\n\n${graphContext}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 04:07:12 +03:00
|
|
|
export function createTeacherAgent(
|
|
|
|
|
model?: LanguageModel,
|
|
|
|
|
tools?: ToolSet,
|
|
|
|
|
graphContext?: string | null,
|
|
|
|
|
// 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.
|
|
|
|
|
onStepFinish?: (step: { usage: LanguageModelUsage }) => void,
|
|
|
|
|
) {
|
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).
|
|
|
|
|
// Reasoning tokens stream as reasoning parts into the UI's collapsible block.
|
|
|
|
|
model: model ?? modelFor("teacher"),
|
2026-08-22 11:41:50 +03:00
|
|
|
instructions: withGraphContext(graphContext),
|
2026-09-01 19:15:43 +03:00
|
|
|
// Tools are per-request (plan tools close over the injected ctx; job
|
|
|
|
|
// tools close over the job store + spawn seam) — chat-handler passes
|
|
|
|
|
// createTeacherTools({ chatId, chatStore, jobStore, spawn }). The
|
2026-08-31 22:16:03 +03:00
|
|
|
// dependency-free default is the question-only set (client-side tools,
|
|
|
|
|
// no storage needed) — used when tests pass a bare mock model.
|
2026-09-01 19:15:43 +03:00
|
|
|
tools: tools ?? { ...questionTools },
|
|
|
|
|
// 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
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|