Files
manbot/src/agents/planner-agent.ts

149 lines
5.7 KiB
TypeScript

/**
* Planner Agent: converts user intent into a structured execution DAG.
* Listens for plan.create, uses Lemonade Adapter + Model Router, validates DAG, emits response.
*/
import { randomUUID } from "node:crypto";
import { BaseProcess } from "../shared/base-process.js";
import type { CapabilityGraph } from "../shared/graph-utils.js";
import { validateGraph } from "../shared/graph-utils.js";
import type { Envelope } from "../shared/protocol.js";
import { responsePayloadSchema } from "../shared/protocol.js";
import { buildPlannerPrompt } from "./prompts/planner.js";
import { ModelRouter } from "../services/model-router.js";
import { LemonadeAdapter } from "../services/lemonade-adapter.js";
import { SkillManager } from "../services/skill-manager.js";
import { ConsoleLogger } from "../utils/console-logger.js";
const PLAN_CREATE = "plan.create";
interface PlanCreatePayload {
goal?: string;
message?: string;
complexity?: "small" | "medium" | "large";
/** When set, a previous attempt failed; use this to produce a corrected plan. */
previousError?: string;
/** Optional previous plan that failed (object). Will be stringified for the prompt. */
previousPlan?: Record<string, unknown>;
/** Optional conversation history for context. */
history?: string;
}
function extractJson(text: string): string {
let s = text.trim();
const start = s.indexOf("{");
const end = s.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
s = s.slice(start, end + 1);
}
return s;
}
export class PlannerAgent extends BaseProcess {
private readonly lemonade: LemonadeAdapter;
private readonly modelRouter: ModelRouter;
private readonly skillManager: SkillManager;
constructor(options?: { lemonade?: LemonadeAdapter; modelRouter?: ModelRouter; skillManager?: SkillManager }) {
super({ processName: "planner" });
this.lemonade = options?.lemonade ?? new LemonadeAdapter();
this.modelRouter = options?.modelRouter ?? new ModelRouter();
this.skillManager = options?.skillManager ?? new SkillManager();
}
protected override handleEnvelope(envelope: Envelope): void {
if (envelope.type !== PLAN_CREATE) return;
const payload = envelope.payload as Record<string, unknown>;
const p = payload as unknown as PlanCreatePayload;
const goal = p.goal ?? p.message ?? "";
const complexity = p.complexity ?? "medium";
if (!goal || typeof goal !== "string") {
this.sendError(envelope, "INVALID_PAYLOAD", "plan.create requires goal or message");
return;
}
(async () => {
try {
const model = this.modelRouter.getModel(complexity);
const previousError = typeof p.previousError === "string" ? p.previousError : undefined;
const previousPlanJson =
p.previousPlan && typeof p.previousPlan === "object"
? JSON.stringify(p.previousPlan, null, 2)
: undefined;
// Load dynamic skills
const skills = this.skillManager.listSkills();
const promptOptions = {
...(previousError && { previousError }),
...(previousPlanJson && { previousPlanJson }),
...(p.history && { conversationHistory: p.history }),
...(skills.length > 0 && { skills }),
};
const prompt = buildPlannerPrompt(goal, promptOptions);
const messages = [
{ role: "system" as const, content: "You output only valid JSON. No markdown, no explanation." },
{ role: "user" as const, content: prompt },
];
const result = await this.lemonade.chat(messages, model);
const raw = result.message?.content ?? "";
ConsoleLogger.debug("planner", `Raw model response (length: ${raw.length})`);
const jsonStr = extractJson(raw);
const dag = JSON.parse(jsonStr) as CapabilityGraph;
if (!dag.nodes || !Array.isArray(dag.nodes)) {
this.sendError(envelope, "INVALID_DAG", "Model output missing nodes array");
return;
}
const validation = validateGraph(dag);
if (!validation.valid) {
this.sendError(envelope, "DAG_VALIDATION_FAILED", validation.errors.join("; "));
return;
}
this.sendResponse(envelope, dag);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.sendError(envelope, "PLANNER_ERROR", message);
}
})();
}
private sendResponse(request: Envelope, result: unknown): void {
const payload = responsePayloadSchema.parse({ status: "success", result });
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "response",
version: "1.0",
timestamp: Date.now(),
payload,
});
}
private sendError(request: Envelope, code: string, message: string): void {
this.send({
id: randomUUID(),
correlationId: request.id,
from: this.processName,
to: request.from,
type: "error",
version: "1.0",
timestamp: Date.now(),
payload: { code, message, details: {} },
});
}
}
function main(): void {
const agent = new PlannerAgent();
agent.start();
}
main();