import { clamp01, createLogger, truncate, type ActionScore, type AgentDecision, type PageState, type SemanticAction } from "@src/shared"; import { scoreActions, type GainContext } from "./InformationGain.ts"; import { extractJson, type LlmClient } from "./llm.ts"; const log = createLogger("planner"); /** * Navigation Agent (§24). Two planners share the same contract: * - HeuristicPlanner (Tier 1): argmax information gain, deterministic. * - LlmPlanner (Tier 4): sees the compact page state + top-scored actions and picks one; the answer is * validated against the action list and falls back to the heuristic on any invalid output. * The LLM never sees selectors or raw DOM and cannot invent actions. */ export interface Planner { readonly name: string; plan(ctx: GainContext, step: number): Promise; readonly tokensUsed: number; } export class HeuristicPlanner implements Planner { readonly name = "heuristic"; readonly tokensUsed = 0; async plan(ctx: GainContext, step: number): Promise { const scores = scoreActions(ctx, ctx.state.actions); return decisionFromScores(ctx, step, scores, "heuristic", undefined); } } export function decisionFromScores(ctx: GainContext, step: number, scores: ActionScore[], planner: AgentDecision["planner"], reason?: string, chosenId?: string): AgentDecision { const best = chosenId ? scores.find((s) => s.action_id === chosenId) ?? scores[0]! : scores[0]!; const action = ctx.state.actions.find((a) => a.id === best.action_id)!; const why = reason ?? explain(ctx, action, best); return { step, goal: ctx.goal, chosen_action: action, expected_information_gain: best.information_gain, novelty: best.novelty, relevance: best.relevance, reason: why, planner, scores: scores.slice(0, 12), }; } function explain(ctx: GainContext, a: SemanticAction, s: ActionScore): string { const e = a.target_ref ? ctx.state.entities.find((x) => x.ref === a.target_ref) : undefined; const parts: string[] = []; if (e) parts.push(`${e.type} not yet visited`); if (s.novelty > 0.7) parts.push("novel content"); if (s.relevance > 0.7) parts.push("high relevance to goal"); if (s.source_quality > 0.9) parts.push("confirmed by network + DOM"); if (a.type === "SCROLL_DOWN") parts.push("reveal more feed items"); if (a.type === "SEARCH") parts.push("native search seeds the topic"); if (a.type === "END_SESSION") parts.push("no productive action left"); if (s.penalties.length) parts.push(`penalties: ${s.penalties.join(",")}`); return parts.join("; ") || "best information gain"; } const SYSTEM_PROMPT = `You are the navigation planner of a research crawler that observes public social-media content through a normal authenticated browser session (read-only: never like, follow, comment, message or subscribe). You receive a compact description of the current page, the entities visible on it, and a numbered list of allowed semantic actions with heuristic information-gain scores. Choose exactly one action id that best advances the goal while avoiding loops and already-visited entities. Reply with a single JSON object: {"action": "", "expected_information_gain": <0..1>, "reason": ""}. No other text.`; export class LlmPlanner implements Planner { readonly name: string; tokensUsed = 0; private failures = 0; constructor(private readonly llm: LlmClient, private readonly fallback: Planner = new HeuristicPlanner(), private readonly maxTokensBudget = 200_000) { this.name = `llm(${llm.name})`; } async plan(ctx: GainContext, step: number): Promise { const scores = scoreActions(ctx, ctx.state.actions); if (this.tokensUsed > this.maxTokensBudget || this.failures >= 3) return decisionFromScores(ctx, step, scores, "fallback", "llm budget exhausted or repeated failures"); // Escalate only when the heuristic is unsure (§50): close top scores, or unknown page. const top = scores[0]!; const second = scores[1]; const unsure = !second || top.information_gain - second.information_gain < top.information_gain * 0.25 || ctx.state.classification.confidence < 0.6; if (!unsure) return decisionFromScores(ctx, step, scores, "heuristic"); try { const user = renderPrompt(ctx, scores); const res = await this.llm.complete({ system: SYSTEM_PROMPT, user, maxTokens: 300 }); this.tokensUsed += res.input_tokens + res.output_tokens; const json = extractJson(res.text); const id = typeof json?.action === "string" ? (json.action as string) : undefined; if (!id || !ctx.state.actions.some((a) => a.id === id)) { this.failures++; log.warn("llm returned invalid action, using heuristic", { reply: truncate(res.text, 120) }); return decisionFromScores(ctx, step, scores, "fallback", "invalid llm output"); } this.failures = 0; const d = decisionFromScores(ctx, step, scores, "llm", typeof json?.reason === "string" ? truncate(json.reason as string, 240) : undefined, id); if (typeof json?.expected_information_gain === "number") d.expected_information_gain = clamp01(json.expected_information_gain as number); return d; } catch (err) { this.failures++; log.warn("llm planner error, using heuristic", { err: (err as Error).message }); return decisionFromScores(ctx, step, scores, "fallback", "llm error"); } } } export function renderPrompt(ctx: GainContext, scores: ActionScore[]): string { const st: PageState = ctx.state; const lines: string[] = []; lines.push(`GOAL: ${ctx.goal}`, `MODE: ${ctx.mode}`, `PLATFORM: ${st.platform}`, `PAGE: ${st.classification.page_type} (${st.classification.confidence.toFixed(2)}) ${truncate(st.title, 80)}`, `URL: ${st.url}`, ""); lines.push("VISIBLE ENTITIES"); for (const e of st.entities.slice(0, 30)) { const flags = [ctx.world.isVisited(e) ? "visited" : "", e.media?.has_video ? "video" : "", e.metrics?.views ? `${e.metrics.views} views` : "", e.metrics?.score ? `score ${e.metrics.score}` : ""].filter(Boolean).join(", "); lines.push(`[${e.ref}] ${e.type}: ${truncate(e.name ?? e.text ?? e.platform_id, 90)}${e.author ? ` — by ${truncate(e.author, 40)}` : ""}${flags ? ` (${flags})` : ""}`); } if (st.entities.length > 30) lines.push(`… ${st.entities.length - 30} more`); lines.push("", "ACTIONS (id · label · heuristic gain)"); const byId = new Map(scores.map((s) => [s.action_id, s])); for (const a of st.actions) { const s = byId.get(a.id); lines.push(`[${a.id}] ${a.label} · gain=${(s?.information_gain ?? 0).toFixed(3)}${s?.penalties.length ? ` · ${s.penalties.join(",")}` : ""}`); } lines.push("", `RECENT ACTIONS: ${ctx.recentActionTypes.slice(-6).join(" → ") || "none"}`, `STEPS WITHOUT NEW ENTITIES: ${ctx.stepsWithoutNewEntities}`); return lines.join("\n"); }