Project Files
src / planning / planner.ts
/**
* @file planning/planner.ts
* Generates search queries and worker decompositions for the swarm.
* Supports dynamic AI task decomposition, inter-agent findings summaries,
* and adaptive gap-fill with targeted worker roles.
*
* Now accepts a DepthProfile so query counts, worker limits, and
* decomposition parameters all scale with the chosen depth preset.
*/
import { LMStudioClient } from "@lmstudio/sdk";
import {
QueryPlan,
WorkerRole,
DynamicWorkerSpec,
AdaptiveGapPlan,
CrawledSource,
AgentMessage,
StatusFn,
} from "../types";
import { DIMENSIONS, detectGaps, gapFillQueries } from "./dimensions";
import {
DepthProfile,
AI_PLANNING_MAX_TOKENS,
AI_PLANNING_TEMPERATURE,
AI_PLANNING_TIMEOUT_MS,
AI_MIN_ACCEPTABLE_QUERIES,
AI_DECOMPOSITION_MAX_TOKENS,
AI_DECOMPOSITION_TEMPERATURE,
AI_DECOMPOSITION_TIMEOUT_MS,
AI_FINDINGS_SUMMARY_MAX_TOKENS,
AI_FINDINGS_SUMMARY_TEMPERATURE,
FINDINGS_SUMMARY_SOURCE_CHARS,
DECOMPOSITION_MIN_WORKERS,
QUERY_LINE_MIN_LEN,
QUERY_LINE_MAX_LEN,
} from "../constants";
async function callLoadedModel(
prompt: string,
maxTokens: number = AI_PLANNING_MAX_TOKENS,
temperature: number = AI_PLANNING_TEMPERATURE,
timeoutMs: number = AI_PLANNING_TIMEOUT_MS,
): Promise<string | null> {
try {
const client = new LMStudioClient();
const models = await Promise.race<
Awaited<ReturnType<typeof client.llm.listLoaded>>
>([
client.llm.listLoaded(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), timeoutMs),
),
]);
if (!Array.isArray(models) || models.length === 0) return null;
const model = await client.llm.model(models[0].identifier);
const stream = model.respond([{ role: "user", content: prompt }], {
maxTokens,
temperature,
});
let result = "";
for await (const chunk of stream) result += chunk.content ?? "";
return result.trim() || null;
} catch {
return null;
}
}
function parseLines(raw: string, maxLines: number = 6): ReadonlyArray<string> {
return raw
.split(/\n/)
.map((line) => line.replace(/^\d+[.)]\s*|^[-*•]\s*/, "").trim())
.filter(
(line) =>
line.length > QUERY_LINE_MIN_LEN && line.length < QUERY_LINE_MAX_LEN,
)
.filter((line, idx, arr) => arr.indexOf(line) === idx)
.slice(0, maxLines);
}
const VALID_ROLES: ReadonlyArray<WorkerRole> = [
"breadth",
"depth",
"recency",
"academic",
"critical",
"statistical",
"regulatory",
"technical",
"primary",
"comparative",
];
function makeDecompositionPrompt(
topic: string,
focusAreas: ReadonlyArray<string>,
profile: DepthProfile,
): string {
const focus = focusAreas.length
? `\nFocus areas: ${focusAreas.join(", ")}`
: "";
return `You are a research decomposition system. Given a research topic, output a JSON array of specialized worker agents.
Topic: "${topic}"${focus}
Each worker needs:
- "role": one of "breadth", "depth", "recency", "academic", "critical", "statistical", "regulatory", "technical", "primary", "comparative"
- "label": descriptive name (e.g., "Clinical Evidence Researcher", "Policy Critic")
- "queries": array of ${Math.min(profile.maxQueriesPerWorker, 6)}-${profile.maxQueriesPerWorker} specific search queries for this worker
- "budgetWeight": number 0.1-0.4 (must sum to ~1.0 across all workers)
- "followLinks": true/false (true for depth/academic workers)
- "preferredTiers": optional array of "academic","government","reference","news","professional","general"
Rules:
- Output ${DECOMPOSITION_MIN_WORKERS} to ${profile.maxDecompositionWorkers} workers
- Tailor the workers to THIS specific topic — not generic roles
- Queries must be highly specific to the topic and each worker's assignment
- Generate MORE queries for broader or more complex topics
- Budget weights must roughly sum to 1.0
- Output ONLY valid JSON, no other text
JSON:`;
}
async function aiDecompose(
topic: string,
focusAreas: ReadonlyArray<string>,
status: StatusFn,
profile: DepthProfile,
): Promise<ReadonlyArray<DynamicWorkerSpec> | null> {
const raw = await callLoadedModel(
makeDecompositionPrompt(topic, focusAreas, profile),
AI_DECOMPOSITION_MAX_TOKENS,
AI_DECOMPOSITION_TEMPERATURE,
AI_DECOMPOSITION_TIMEOUT_MS,
);
if (!raw) return null;
try {
const jsonStr = raw.replace(/```json\s*|```\s*/g, "").trim();
const parsed = JSON.parse(jsonStr);
if (!Array.isArray(parsed) || parsed.length < DECOMPOSITION_MIN_WORKERS)
return null;
const specs: DynamicWorkerSpec[] = [];
for (const item of parsed.slice(0, profile.maxDecompositionWorkers)) {
const role = VALID_ROLES.includes(item.role) ? item.role : "breadth";
const queries = Array.isArray(item.queries)
? item.queries
.filter((q: unknown) => typeof q === "string" && q.length > 3)
.slice(0, profile.maxQueriesPerWorker)
: [];
if (queries.length < 2) continue;
specs.push({
role: role as WorkerRole,
label:
typeof item.label === "string"
? item.label.slice(0, 60)
: `${role} worker`,
queries,
budgetWeight:
typeof item.budgetWeight === "number"
? Math.max(0.05, Math.min(0.5, item.budgetWeight))
: 0.2,
followLinks: item.followLinks === true,
preferredTiers: Array.isArray(item.preferredTiers)
? item.preferredTiers
: undefined,
});
}
if (specs.length < DECOMPOSITION_MIN_WORKERS) return null;
const totalWeight = specs.reduce((sum, s) => sum + s.budgetWeight, 0);
const normalised = specs.map((s) => ({
...s,
budgetWeight: s.budgetWeight / totalWeight,
}));
status(`AI decomposed topic into ${normalised.length} specialised workers`);
return normalised;
} catch {
return null;
}
}
function makeRolePlanPrompt(
role: WorkerRole,
topic: string,
focusAreas: ReadonlyArray<string>,
profile: DepthProfile,
): string {
const roleDescriptions: Readonly<Record<WorkerRole, string>> = {
breadth: "broad coverage — many different angles, facts, and sub-topics",
depth:
"deep dive — mechanisms, how it works, technical detail, and evidence",
recency:
"recent developments — 2024-2026+ news, updates, and latest research",
academic:
"academic and scientific sources — peer-reviewed studies, journals, authoritative papers",
critical:
"critical analysis — limitations, counterarguments, criticism, controversy, drawbacks",
statistical:
"statistics and data — numbers, percentages, datasets, surveys, market sizes, quantitative evidence",
regulatory:
"regulatory and policy — laws, regulations, government policies, compliance, standards, guidelines",
technical:
"technical deep-dive — implementation details, specifications, architecture, engineering approaches",
primary:
"primary sources — original reports, official statements, first-hand accounts, press releases, white papers",
comparative:
"comparative analysis — vs alternatives, head-to-head comparisons, benchmarks, trade-offs, pros and cons",
};
const focus = focusAreas.length
? `\nFocus especially on: ${focusAreas.join(", ")}`
: "";
return `You are a research planning assistant. Generate search queries for a specialised research agent.
Topic: "${topic}"${focus}
This agent's role: ${roleDescriptions[role]}
Generate exactly ${profile.maxQueriesPerWorker} highly specific, diverse search queries for this role.
Rules:
- Each query must be different from the others
- Use natural language (as a human would type into a search engine)
- Be specific to the role — ${roleDescriptions[role]}
- Vary query structure: some factual, some comparative, some recent
- Return ONLY the queries, one per line, no numbering, no extra text
Queries:`;
}
const ROLE_DIMENSIONS: Readonly<Record<WorkerRole, ReadonlyArray<string>>> = {
breadth: ["overview", "applications", "history", "economics"],
depth: ["mechanism", "evidence", "expert"],
recency: ["current", "future"],
academic: ["evidence", "expert", "mechanism"],
critical: ["challenges", "controversy", "comparison"],
statistical: ["evidence", "economics", "overview"],
regulatory: ["challenges", "controversy", "current"],
technical: ["mechanism", "applications", "evidence"],
primary: ["evidence", "expert", "history"],
comparative: ["comparison", "challenges", "applications"],
};
function dimensionFallbackQueries(
role: WorkerRole,
topic: string,
focusAreas: ReadonlyArray<string>,
maxQueries: number,
): ReadonlyArray<string> {
const dimIds = ROLE_DIMENSIONS[role];
const dims = DIMENSIONS.filter((d) => dimIds.includes(d.id));
const queries: string[] = [];
const shortTopic = shortenTopic(topic);
for (const dim of dims) {
for (const q of dim.queries(shortTopic)) {
if (!queries.includes(q)) queries.push(q);
}
}
for (const area of focusAreas) {
const q = `${shortTopic} ${area}`;
if (!queries.includes(q)) queries.push(q);
}
return queries.slice(0, maxQueries);
}
/** Builds a full QueryPlan using AI decomposition, per-role planning, or dimension fallback. */
export async function buildQueryPlan(
topic: string,
focusAreas: ReadonlyArray<string>,
useAI: boolean,
status: StatusFn,
profile: DepthProfile,
): Promise<QueryPlan> {
const CORE_ROLES: ReadonlyArray<WorkerRole> = [
"breadth",
"depth",
"recency",
"academic",
"critical",
];
const EXTENDED_ROLES: ReadonlyArray<WorkerRole> = [
"statistical",
"regulatory",
"technical",
"primary",
"comparative",
];
let roles: ReadonlyArray<WorkerRole>;
if (profile.depthRounds >= 10) {
roles = [...CORE_ROLES, ...EXTENDED_ROLES];
} else if (profile.depthRounds >= 5) {
roles = [...CORE_ROLES, "technical", "comparative", "statistical"];
} else {
roles = CORE_ROLES;
}
const queriesByRole: Partial<Record<WorkerRole, ReadonlyArray<string>>> = {};
let usedAI = false;
let dynamicSpecs: ReadonlyArray<DynamicWorkerSpec> | undefined;
if (useAI) {
status("AI task decomposition — analysing topic for specialised workers…");
const specs = await aiDecompose(topic, focusAreas, status, profile);
if (specs && specs.length >= DECOMPOSITION_MIN_WORKERS) {
dynamicSpecs = specs;
usedAI = true;
for (const spec of specs) {
queriesByRole[spec.role] = spec.queries;
}
} else {
status("AI planning queries for each swarm worker…");
}
const uncoveredRoles = roles.filter((r) => !queriesByRole[r]?.length);
if (uncoveredRoles.length > 0) {
const results = await Promise.allSettled(
uncoveredRoles.map(async (role) => ({
role,
queries: await callLoadedModel(
makeRolePlanPrompt(role, topic, focusAreas, profile),
),
})),
);
for (const result of results) {
if (result.status !== "fulfilled") continue;
const { role, queries: raw } = result.value;
if (!raw) continue;
const parsed = parseLines(raw, profile.maxQueriesPerWorker);
if (parsed.length >= AI_MIN_ACCEPTABLE_QUERIES) {
queriesByRole[role] = parsed;
usedAI = true;
}
}
}
if (usedAI) {
status(
`AI generated queries for ${Object.keys(queriesByRole).length} worker role(s)`,
);
} else {
status("AI unavailable, using dimension-based query planning");
}
}
for (const role of roles) {
if (!queriesByRole[role] || queriesByRole[role]!.length === 0) {
queriesByRole[role] = dimensionFallbackQueries(
role,
topic,
focusAreas,
profile.maxQueriesPerWorker,
);
}
}
return {
queriesByRole: queriesByRole as Record<WorkerRole, ReadonlyArray<string>>,
usedAI,
topicKeywords: extractKeywords(topic),
dynamicSpecs,
};
}
/** Summarises Round 1 findings so gap-fill workers have context. */
export async function summariseFindings(
sources: ReadonlyArray<CrawledSource>,
topic: string,
useAI: boolean,
status: StatusFn,
): Promise<ReadonlyArray<AgentMessage>> {
if (!useAI || sources.length === 0) return [];
const sourceSummaries = sources
.slice(0, 20)
.map(
(s, i) =>
`[${i + 1}] ${s.workerLabel}: ${s.title} — ${s.text.slice(0, FINDINGS_SUMMARY_SOURCE_CHARS)}`,
)
.join("\n\n");
const prompt = `You are a research coordinator. A team of research agents collected these sources on "${topic}":
${sourceSummaries}
Summarise:
1. The 3-5 most important findings discovered so far (one line each)
2. 3-5 specific questions or angles that were NOT covered and need follow-up
Output format:
FINDINGS:
- finding 1
- finding 2
...
FOLLOW_UP:
- question 1
- question 2
...`;
const raw = await callLoadedModel(
prompt,
AI_FINDINGS_SUMMARY_MAX_TOKENS,
AI_FINDINGS_SUMMARY_TEMPERATURE,
);
if (!raw) return [];
const findings: string[] = [];
const followUps: string[] = [];
let section: "findings" | "followup" | null = null;
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (/^FINDINGS:/i.test(trimmed)) {
section = "findings";
continue;
}
if (/^FOLLOW.?UP:/i.test(trimmed)) {
section = "followup";
continue;
}
const item = trimmed.replace(/^[-*•]\s*/, "").trim();
if (item.length < 5) continue;
if (section === "findings") findings.push(item);
if (section === "followup") followUps.push(item);
}
if (findings.length === 0 && followUps.length === 0) return [];
status(
`AI summarised ${findings.length} key findings, ${followUps.length} follow-up suggestions`,
);
return [
{
fromWorker: "round-coordinator",
keyFindings: findings,
suggestedFollowUps: followUps,
},
];
}
/** Maps each dimension to the worker role best suited to fill it. */
const GAP_ROLE_MAP: Readonly<
Record<
string,
{
role: WorkerRole;
followLinks: boolean;
tiers?: ReadonlyArray<import("../types").SourceTier>;
}
>
> = {
overview: { role: "breadth", followLinks: false },
mechanism: { role: "technical", followLinks: true },
history: { role: "breadth", followLinks: false },
current: { role: "recency", followLinks: false },
applications: { role: "breadth", followLinks: false },
challenges: { role: "critical", followLinks: false },
comparison: { role: "comparative", followLinks: false },
evidence: {
role: "academic",
followLinks: true,
tiers: ["academic", "government", "reference"],
},
expert: { role: "primary", followLinks: true, tiers: ["academic", "news"] },
future: { role: "recency", followLinks: false },
controversy: { role: "critical", followLinks: false },
economics: { role: "statistical", followLinks: false },
};
/** Generates adaptive gap-fill plans with targeted worker roles per gap. */
export async function buildAdaptiveGapFill(
topic: string,
coveredIds: ReadonlyArray<string>,
priorMessages: ReadonlyArray<AgentMessage>,
useAI: boolean,
status: StatusFn,
profile: DepthProfile,
): Promise<ReadonlyArray<AdaptiveGapPlan>> {
const gaps = detectGaps(coveredIds);
if (gaps.length === 0) {
status("All research dimensions covered — no gap queries needed");
return [];
}
status(`Gaps: ${gaps.map((g) => g.label).join(", ")}`);
const byRole = new Map<
WorkerRole,
{
dimIds: string[];
dimLabels: string[];
queries: string[];
followLinks: boolean;
tiers?: ReadonlyArray<import("../types").SourceTier>;
}
>();
const shortTopic = shortenTopic(topic);
for (const gap of gaps) {
const mapping = GAP_ROLE_MAP[gap.id] ?? {
role: "breadth" as WorkerRole,
followLinks: false,
};
const existing = byRole.get(mapping.role) ?? {
dimIds: [],
dimLabels: [],
queries: [],
followLinks: mapping.followLinks,
tiers: mapping.tiers,
};
existing.dimIds.push(gap.id);
existing.dimLabels.push(gap.label);
existing.queries.push(...gap.queries(shortTopic));
byRole.set(mapping.role, existing);
}
if (useAI) {
const followUpContext = priorMessages
.flatMap((m) => m.suggestedFollowUps)
.slice(0, 6);
const entries = Array.from(byRole.entries());
const aiResults = await Promise.allSettled(
entries.map(async ([role, group]) => {
const queryCount = Math.min(
group.dimLabels.length * 3,
profile.maxGapFillQueries,
);
const prompt = `You are a research assistant. A research session on "${topic}" is missing these angles:
${group.dimLabels.join(", ")}
${followUpContext.length > 0 ? `Previous round suggested exploring:\n${followUpContext.join("\n")}\n` : ""}
Generate ${queryCount} specific search queries to fill these gaps.
The queries should be best suited for a ${role} research agent.
Make queries diverse — cover different angles and phrasings.
Return ONLY the queries, one per line.
Queries:`;
return { role, raw: await callLoadedModel(prompt) };
}),
);
for (const result of aiResults) {
if (result.status !== "fulfilled" || !result.value.raw) continue;
const { role, raw } = result.value;
const parsed = parseLines(raw, profile.maxGapFillQueries);
if (parsed.length >= 2) {
const group = byRole.get(role);
if (group) group.queries = [...parsed];
}
}
}
const plans: AdaptiveGapPlan[] = [];
for (const [role, group] of byRole) {
plans.push({
role,
label: `Gap-fill: ${group.dimLabels.slice(0, 3).join(", ")}`,
queries: group.queries.slice(0, profile.maxGapFillQueries),
followLinks: group.followLinks,
preferredTiers: group.tiers,
});
}
status(`${plans.length} adaptive gap-fill worker(s) planned`);
return plans;
}
const STOP_WORDS = new Set([
"the",
"a",
"an",
"is",
"in",
"of",
"and",
"or",
"for",
"to",
"how",
"what",
"why",
"when",
"does",
"with",
"from",
"that",
"could",
"which",
"about",
"their",
"this",
"these",
"those",
"would",
"should",
"current",
"hypothetical",
"scenarios",
"lead",
]);
function extractKeywords(topic: string): ReadonlyArray<string> {
return topic
.toLowerCase()
.replace(/[^a-z0-9\s]/g, "")
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP_WORDS.has(w))
.slice(0, 8);
}
function shortenTopic(topic: string): string {
const colonIdx = topic.indexOf(":");
const dashIdx = topic.indexOf(" — ");
const sepIdx = colonIdx > 3 ? colonIdx : dashIdx > 3 ? dashIdx : -1;
let core: string;
if (sepIdx > 3 && sepIdx < topic.length * 0.6) {
core = topic.slice(0, sepIdx).trim();
} else {
core = topic;
}
const words = core
.replace(/[,;()]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 1 && !STOP_WORDS.has(w.toLowerCase()));
let result = "";
let count = 0;
for (const w of words) {
if (count >= 6 || result.length + w.length > 58) break;
result += (result ? " " : "") + w;
count++;
}
if (result.length < 5) {
result = topic.split(/\s+/).slice(0, 5).join(" ");
}
return result;
}