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";
|
|
|
|
|
import { ToolLoopAgent, stepCountIs, type LanguageModel, type ToolSet } from "ai";
|
2026-08-22 02:18:27 +03:00
|
|
|
import { openrouter } from "@openrouter/ai-sdk-provider";
|
|
|
|
|
import { PATHS } from "../../config.ts";
|
2026-08-22 09:06:24 +03:00
|
|
|
import { createTeacherTools } 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
|
|
|
*
|
|
|
|
|
* 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-08-22 02:18:27 +03:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const DEFAULT_MODEL = process.env.LH_MODEL ?? "anthropic/claude-sonnet-4.5";
|
|
|
|
|
|
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-08-22 09:06:24 +03:00
|
|
|
function composeInstructions(): string {
|
|
|
|
|
const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
|
|
|
|
if (!fs.existsSync(PROMPTS_DIR)) return base;
|
|
|
|
|
const fragments = fs
|
|
|
|
|
.readdirSync(PROMPTS_DIR)
|
|
|
|
|
.filter((f) => f.endsWith(".md"))
|
|
|
|
|
.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();
|
|
|
|
|
|
|
|
|
|
export function createTeacherAgent(model?: LanguageModel, tools?: ToolSet) {
|
2026-08-22 02:18:27 +03:00
|
|
|
return new ToolLoopAgent({
|
|
|
|
|
model:
|
|
|
|
|
model ??
|
|
|
|
|
openrouter(DEFAULT_MODEL, {
|
|
|
|
|
// Ask OpenRouter for reasoning tokens when the model supports them;
|
|
|
|
|
// they stream as reasoning parts into the UI's collapsible block.
|
|
|
|
|
reasoning: { enabled: true, effort: "medium" },
|
|
|
|
|
}),
|
|
|
|
|
instructions,
|
2026-08-22 09:06:24 +03:00
|
|
|
// Tools are per-request (plan tools close over chatId); chat-handler
|
|
|
|
|
// passes createTeacherTools({ chatId }). Tests pass a mock model and get
|
|
|
|
|
// the default question-only set.
|
|
|
|
|
tools: tools ?? createTeacherTools({ chatId: "" }),
|
|
|
|
|
// Question and job tools are client-side — a call ends the server turn
|
|
|
|
|
// automatically. stepCountIs is just the safety net.
|
2026-08-22 02:18:27 +03:00
|
|
|
stopWhen: stepCountIs(20),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|