Add mesh auto-planning with chat command UX and hardened auth/session behavior

This commit is contained in:
ranausmanai
2026-02-16 20:49:44 +05:00
committed by Peter Steinberger
parent 83990ed542
commit 16e59b26a6
14 changed files with 862 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import { __resetMeshRunsForTest, meshHandlers } from "./mesh.js";
const mocks = vi.hoisted(() => ({
agent: vi.fn(),
agentWait: vi.fn(),
agentCommand: vi.fn(),
}));
vi.mock("./agent.js", () => ({
@@ -14,6 +15,10 @@ vi.mock("./agent.js", () => ({
},
}));
vi.mock("../../commands/agent.js", () => ({
agentCommand: (...args: unknown[]) => mocks.agentCommand(...args),
}));
const makeContext = (): GatewayRequestContext =>
({
dedupe: new Map(),
@@ -38,6 +43,7 @@ afterEach(() => {
__resetMeshRunsForTest();
mocks.agent.mockReset();
mocks.agentWait.mockReset();
mocks.agentCommand.mockReset();
});
describe("mesh handlers", () => {
@@ -135,4 +141,86 @@ describe("mesh handlers", () => {
const statusPayload = statusRes.payload as { status: string };
expect(statusPayload.status).toBe("completed");
});
it("auto planner creates multiple steps from llm json output", async () => {
mocks.agentCommand.mockResolvedValue({
payloads: [
{
text: JSON.stringify({
steps: [
{ id: "analyze", prompt: "Analyze requirements" },
{ id: "build", prompt: "Build implementation", dependsOn: ["analyze"] },
],
}),
},
],
meta: {},
});
const res = await callMesh("mesh.plan.auto", {
goal: "Create dashboard with auth",
maxSteps: 4,
});
expect(res.ok).toBe(true);
const payload = res.payload as {
source: string;
plan: { steps: Array<{ id: string }> };
order: string[];
};
expect(payload.source).toBe("llm");
expect(payload.plan.steps.map((s) => s.id)).toEqual(["analyze", "build"]);
expect(payload.order).toEqual(["analyze", "build"]);
expect(mocks.agentCommand).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "main",
sessionKey: "agent:main:mesh-planner",
}),
expect.any(Object),
undefined,
);
});
it("auto planner falls back to single-step plan when llm output is invalid", async () => {
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "not valid json" }],
meta: {},
});
const res = await callMesh("mesh.plan.auto", {
goal: "Do a thing",
});
expect(res.ok).toBe(true);
const payload = res.payload as {
source: string;
plan: { steps: Array<{ id: string; prompt: string }> };
};
expect(payload.source).toBe("fallback");
expect(payload.plan.steps).toHaveLength(1);
expect(payload.plan.steps[0]?.prompt).toBe("Do a thing");
});
it("auto planner respects caller-provided planner session key", async () => {
mocks.agentCommand.mockResolvedValue({
payloads: [
{
text: JSON.stringify({
steps: [{ id: "one", prompt: "One" }],
}),
},
],
meta: {},
});
const res = await callMesh("mesh.plan.auto", {
goal: "Do a thing",
sessionKey: "agent:main:custom-planner",
});
expect(res.ok).toBe(true);
expect(mocks.agentCommand).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:custom-planner",
}),
expect.any(Object),
undefined,
);
});
});

View File

@@ -1,13 +1,18 @@
import { randomUUID } from "node:crypto";
import { agentCommand } from "../../commands/agent.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateMeshPlanAutoParams,
validateMeshPlanParams,
validateMeshRetryParams,
validateMeshRunParams,
validateMeshStatusParams,
type MeshPlanAutoParams,
type MeshRunParams,
type MeshWorkflowPlan,
} from "../protocol/index.js";
@@ -48,8 +53,25 @@ type MeshRunRecord = {
history: Array<{ ts: number; type: string; stepId?: string; data?: Record<string, unknown> }>;
};
type MeshAutoStep = {
id?: string;
name?: string;
prompt: string;
dependsOn?: string[];
agentId?: string;
sessionKey?: string;
thinking?: string;
timeoutMs?: number;
};
type MeshAutoPlanShape = {
steps?: MeshAutoStep[];
};
const meshRuns = new Map<string, MeshRunRecord>();
const MAX_KEEP_RUNS = 200;
const AUTO_PLAN_TIMEOUT_MS = 90_000;
const PLANNER_MAIN_KEY = "mesh-planner";
function trimMap() {
if (meshRuns.size <= MAX_KEEP_RUNS) {
@@ -103,16 +125,7 @@ function normalizePlan(plan: MeshWorkflowPlan): MeshWorkflowPlan {
function createPlanFromParams(params: {
goal: string;
steps?: Array<{
id?: string;
name?: string;
prompt: string;
dependsOn?: string[];
agentId?: string;
sessionKey?: string;
thinking?: string;
timeoutMs?: number;
}>;
steps?: MeshAutoStep[];
}): MeshWorkflowPlan {
const now = Date.now();
const goal = params.goal.trim();
@@ -431,6 +444,7 @@ async function runWorkflow(run: MeshRunRecord, opts: GatewayRequestHandlerOption
const inFlight = new Set<Promise<void>>();
let stopScheduling = false;
while (true) {
const failed = Object.values(run.steps).some((step) => step.status === "failed");
if (failed && !run.continueOnError) {
@@ -459,6 +473,7 @@ async function runWorkflow(run: MeshRunRecord, opts: GatewayRequestHandlerOption
if (pending.length === 0) {
break;
}
for (const step of pending) {
step.status = "skipped";
step.endedAt = Date.now();
@@ -547,6 +562,129 @@ function summarizeRun(run: MeshRunRecord) {
};
}
function extractTextFromAgentResult(result: unknown): string {
const payloads = (result as { payloads?: Array<{ text?: unknown }> } | undefined)?.payloads;
if (!Array.isArray(payloads)) {
return "";
}
const texts: string[] = [];
for (const payload of payloads) {
if (typeof payload?.text === "string" && payload.text.trim()) {
texts.push(payload.text.trim());
}
}
return texts.join("\n\n");
}
function parseJsonObjectFromText(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
// keep trying
}
const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (fenceMatch?.[1]) {
try {
const parsed = JSON.parse(fenceMatch[1]);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
// keep trying
}
}
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start >= 0 && end > start) {
const candidate = trimmed.slice(start, end + 1);
try {
const parsed = JSON.parse(candidate);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
return null;
}
function buildAutoPlannerPrompt(params: { goal: string; maxSteps: number }) {
return [
"You are a workflow planner. Convert the user's goal into executable workflow steps.",
"Return STRICT JSON only, no markdown, no prose.",
'JSON schema: {"steps": [{"id": string, "name"?: string, "prompt": string, "dependsOn"?: string[]}]}',
"Rules:",
`- Use 2 to ${params.maxSteps} steps.`,
"- Keep ids short, lowercase, kebab-case.",
"- dependsOn must reference earlier step ids when needed.",
"- prompts must be concrete and executable by an AI coding assistant.",
"- Do not include extra fields.",
`Goal: ${params.goal}`,
].join("\n");
}
async function generateAutoPlan(params: {
goal: string;
maxSteps: number;
agentId?: string;
sessionKey?: string;
thinking?: string;
timeoutMs?: number;
lane?: string;
opts: GatewayRequestHandlerOptions;
}): Promise<{ plan: MeshWorkflowPlan; source: "llm" | "fallback"; plannerText?: string }> {
const prompt = buildAutoPlannerPrompt({ goal: params.goal, maxSteps: params.maxSteps });
const timeoutSeconds = Math.ceil((params.timeoutMs ?? AUTO_PLAN_TIMEOUT_MS) / 1000);
const resolvedAgentId = normalizeAgentId(params.agentId ?? "main");
const plannerSessionKey = params.sessionKey?.trim() || `agent:${resolvedAgentId}:${PLANNER_MAIN_KEY}`;
try {
const runResult = await agentCommand(
{
message: prompt,
deliver: false,
timeout: String(timeoutSeconds),
agentId: resolvedAgentId,
sessionKey: plannerSessionKey,
...(params.thinking ? { thinking: params.thinking } : {}),
...(params.lane ? { lane: params.lane } : {}),
},
defaultRuntime,
params.opts.context.deps,
);
const text = extractTextFromAgentResult(runResult);
const parsed = parseJsonObjectFromText(text) as MeshAutoPlanShape | null;
const rawSteps = Array.isArray(parsed?.steps) ? parsed.steps : [];
if (rawSteps.length > 0) {
const plan = normalizePlan(
createPlanFromParams({
goal: params.goal,
steps: rawSteps.slice(0, params.maxSteps),
}),
);
return { plan, source: "llm", plannerText: text };
}
const fallbackPlan = normalizePlan(createPlanFromParams({ goal: params.goal }));
return { plan: fallbackPlan, source: "fallback", plannerText: text };
} catch {
const fallbackPlan = normalizePlan(createPlanFromParams({ goal: params.goal }));
return { plan: fallbackPlan, source: "fallback" };
}
}
export const meshHandlers: GatewayRequestHandlers = {
"mesh.plan": ({ params, respond }) => {
if (!validateMeshPlanParams(params)) {
@@ -581,6 +719,56 @@ export const meshHandlers: GatewayRequestHandlers = {
undefined,
);
},
"mesh.plan.auto": async ({ params, respond, ...rest }) => {
if (!validateMeshPlanAutoParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid mesh.plan.auto params: ${formatValidationErrors(validateMeshPlanAutoParams.errors)}`,
),
);
return;
}
const p = params as MeshPlanAutoParams;
const maxSteps =
typeof p.maxSteps === "number" && Number.isFinite(p.maxSteps)
? Math.max(1, Math.min(16, Math.floor(p.maxSteps)))
: 6;
const auto = await generateAutoPlan({
goal: p.goal,
maxSteps,
agentId: p.agentId,
sessionKey: p.sessionKey,
thinking: p.thinking,
timeoutMs: p.timeoutMs,
lane: p.lane,
opts: {
...rest,
params,
respond,
},
});
const graph = validatePlanGraph(auto.plan);
if (!graph.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, graph.error));
return;
}
respond(
true,
{
plan: auto.plan,
order: graph.order,
source: auto.source,
plannerText: auto.plannerText,
},
undefined,
);
},
"mesh.run": async (opts) => {
const { params, respond } = opts;
if (!validateMeshRunParams(params)) {
@@ -640,7 +828,7 @@ export const meshHandlers: GatewayRequestHandlers = {
}
const run = meshRuns.get(params.runId.trim());
if (!run) {
respond(false, undefined, errorShape(ErrorCodes.NOT_FOUND, "mesh run not found"));
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "mesh run not found"));
return;
}
respond(true, summarizeRun(run), undefined);
@@ -661,7 +849,7 @@ export const meshHandlers: GatewayRequestHandlers = {
const runId = params.runId.trim();
const run = meshRuns.get(runId);
if (!run) {
respond(false, undefined, errorShape(ErrorCodes.NOT_FOUND, "mesh run not found"));
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "mesh run not found"));
return;
}
if (run.status === "running") {

View File

@@ -500,6 +500,50 @@ describe("gateway healthHandlers.status scope handling", () => {
});
});
describe("gateway mesh.plan.auto scope handling", () => {
it("rejects operator.read clients for mesh.plan.auto", async () => {
const { handleGatewayRequest } = await import("../server-methods.js");
const respond = vi.fn();
const handler = vi.fn();
await handleGatewayRequest({
req: { id: "req-mesh-read", type: "req", method: "mesh.plan.auto", params: {} },
respond,
context: {} as Parameters<typeof handleGatewayRequest>[0]["context"],
client: { connect: { role: "operator", scopes: ["operator.read"] } },
isWebchatConnect: () => false,
extraHandlers: { "mesh.plan.auto": handler },
});
expect(handler).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "missing scope: operator.write" }),
);
});
it("allows operator.write clients for mesh.plan.auto", async () => {
const { handleGatewayRequest } = await import("../server-methods.js");
const respond = vi.fn();
const handler = vi.fn(({ respond: send }: { respond: (ok: boolean, payload?: unknown) => void }) =>
send(true, { ok: true }),
);
await handleGatewayRequest({
req: { id: "req-mesh-write", type: "req", method: "mesh.plan.auto", params: {} },
respond,
context: {} as Parameters<typeof handleGatewayRequest>[0]["context"],
client: { connect: { role: "operator", scopes: ["operator.write"] } },
isWebchatConnect: () => false,
extraHandlers: { "mesh.plan.auto": handler },
});
expect(handler).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith(true, { ok: true });
});
});
describe("logs.tail", () => {
const logsNoop = () => false;