Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
computeLocalScorerTokens,
} from "@loopover/engine";
import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "@loopover/engine/signals/slop";
// #6754: the same pure evaluator the remote MCP tool + /v1/loop/evaluate-escalation both call.
import { evaluateEscalation } from "@loopover/engine";
import { z } from "zod";
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";
import { formatTable } from "../lib/format-table.js";
Expand Down Expand Up @@ -532,6 +534,15 @@ const validateConfigShape = {
source: z.enum(["repo_file", "api_record", "none"]).optional(),
};

// #6754: mirrors evaluateEscalationShape in src/mcp/server.ts exactly, so the local tool, the remote tool, and
// the REST route all accept an identical payload.
const evaluateEscalationShape = {
runStatus: z.enum(["running", "converged", "abandoned", "error"]),
healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(),
customerFlagged: z.boolean().optional(),
killRequested: z.boolean().optional(),
};

const checkSlopRiskShape = {
changedFiles: z
.array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() }))
Expand Down Expand Up @@ -841,6 +852,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "review",
description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. Computed in-process; no repo data and no API round-trip.",
},
{
name: "loopover_evaluate_escalation",
category: "agent",
description:
"Decide whether a rented loop needs a human, and what action to take, from an already-computed run outcome, health tier, and operator/customer signals — the deterministic support/escalation-path logic. Source-free; returns shouldEscalate + action (none/notify/human_review/stop) + severity + reasons. It decides; the caller wires the action. Computed in-process; no API round-trip.",
},
{
name: "loopover_check_issue_slop",
category: "review",
Expand Down Expand Up @@ -1401,6 +1418,18 @@ registerStdioTool(
(input) => toolResult("LoopOver slop-risk self-check.", { ...buildSlopAssessment(input), rubric: SLOP_RUBRIC_MARKDOWN }),
);

registerStdioTool(
"loopover_evaluate_escalation",
{
description: stdioToolDescription("loopover_evaluate_escalation"),
inputSchema: evaluateEscalationShape,
},
// Computed in-process from @loopover/engine (#6754) — the same pure evaluateEscalation the remote server
// (src/mcp/server.ts) and the /v1/loop/evaluate-escalation route both call, so all three surfaces return a
// byte-identical decision for identical input, and escalation checks work fully offline.
(input) => toolResult("LoopOver escalation decision.", evaluateEscalation(input)),
);

registerStdioTool(
"loopover_check_issue_slop",
{
Expand Down
21 changes: 21 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import {
} from "../services/control-panel-roles";
import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities";
import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag";
import { evaluateEscalation } from "../loop-escalation";
import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings";
import {
buildMcpCompatibilityMetadata,
Expand Down Expand Up @@ -478,6 +479,15 @@ const validateFocusManifestSchema = z.object({

// Pure local-metadata slop self-checks (no repo data, no secrets) — mirror the loopover_check_slop_risk /
// loopover_check_issue_slop MCP tools so the npm package can offer the same agent-native self-check.
// #6754: mirrors the loopover_evaluate_escalation MCP tool's input shape exactly (src/mcp/server.ts) so the
// REST surface can never accept something the tool would reject, or vice versa.
const evaluateEscalationSchema = z.object({
runStatus: z.enum(["running", "converged", "abandoned", "error"]),
healthStatus: z.enum(["healthy", "degraded", "critical"]).optional(),
customerFlagged: z.boolean().optional(),
killRequested: z.boolean().optional(),
});

const slopRiskSchema = z.object({
changedFiles: z
.array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() }))
Expand Down Expand Up @@ -3218,6 +3228,17 @@ export function createApp() {
return c.json({ ...buildSlopAssessment(parsed.data), rubric: SLOP_RUBRIC_MARKDOWN });
});

// #6754: REST mirror of the loopover_evaluate_escalation MCP tool, bringing it to the same REST/CLI parity
// its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk, directly above) already has. Both are
// pure, source-free evaluators over caller-supplied data, so this route delegates to the same
// `evaluateEscalation` the tool calls and adds no logic of its own -- it decides; the caller wires the action.
app.post("/v1/loop/evaluate-escalation", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = evaluateEscalationSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_evaluate_escalation_request", issues: parsed.error.issues }, 400);
return c.json(evaluateEscalation(parsed.data));
});

app.post("/v1/lint/issue-slop", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = issueSlopSchema.safeParse(body);
Expand Down
73 changes: 73 additions & 0 deletions test/unit/mcp-cli-evaluate-escalation-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { evaluateEscalation } from "../../src/loop-escalation";

// #6754: the local mirror of loopover_evaluate_escalation. Like its same-tier sibling loopover_check_slop_risk,
// it computes IN-PROCESS from @loopover/engine — no API round-trip — so escalation checks work fully offline.
// The point of these tests is cross-surface PARITY: the stdio tool must return exactly what the pure
// evaluateEscalation returns for identical input (the same function /v1/loop/evaluate-escalation delegates to).
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");

let client: Client;
let transport: StdioClientTransport;
let configDir: string;

beforeEach(async () => {
configDir = mkdtempSync(join(tmpdir(), "loopover-eval-escalation-"));
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
// Pure + in-process: a black-holed API URL proves no round-trip happens.
env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_URL: "http://127.0.0.1:1", LOOPOVER_API_TIMEOUT_MS: "1000" },
});
client = new Client({ name: "eval-escalation-test", version: "0.0.1" });
await client.connect(transport);
});

afterEach(async () => {
await client?.close().catch(() => undefined);
if (configDir) rmSync(configDir, { recursive: true, force: true });
});

describe("loopover_evaluate_escalation stdio mirror (#6754)", () => {
it("registers the tool alongside its same-tier check_slop_risk sibling", async () => {
const names = new Set((await client.listTools()).tools.map((t) => t.name));
expect(names).toContain("loopover_evaluate_escalation");
expect(names).toContain("loopover_check_slop_risk");
});

it("matches the pure evaluator for every precedence arm — offline, with no API reachable", async () => {
const cases = [
{ runStatus: "running", killRequested: true },
{ runStatus: "error" },
{ runStatus: "running", healthStatus: "critical" },
{ runStatus: "abandoned" },
{ runStatus: "running", customerFlagged: true },
{ runStatus: "running", healthStatus: "degraded" },
{ runStatus: "running", healthStatus: "healthy" },
{ runStatus: "converged" },
] as const;
for (const args of cases) {
const result = await client.callTool({ name: "loopover_evaluate_escalation", arguments: args });
expect(result.isError, JSON.stringify(args)).toBeFalsy();
// PARITY: identical to what the REST route returns, because both call this same function.
expect((result as { structuredContent?: unknown }).structuredContent, JSON.stringify(args)).toEqual(
JSON.parse(JSON.stringify(evaluateEscalation(args))),
);
}
});

it("rejects invalid input (zod input-schema validation)", async () => {
for (const args of [{}, { runStatus: "bogus" }, { runStatus: "running", healthStatus: "on-fire" }, { runStatus: "running", killRequested: "yes" }]) {
const rejected = await client.callTool({ name: "loopover_evaluate_escalation", arguments: args }).then(
(r) => Boolean(r.isError),
() => true,
);
expect(rejected, `${JSON.stringify(args)} should be rejected`).toBe(true);
}
});
});
13 changes: 7 additions & 6 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// canonical loopover_-prefixed stdio tools are registered, none of their old gittensory_-prefixed
// alias names resolve anymore, no description carries a stale deprecation notice, and the CLI's
// `tools --json` listing stays in lockstep with what the live server actually registers.
// (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.)
// (#6754 registered the evaluate-escalation mirror, taking the count from 64 to 65.)
// (#// (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.)
// (#6150 registered the local-scorer and plan-DAG/predict-gate tools, taking the count from 55 to 60.)
// (#6619 registered the pr-ai-review-findings CLI mirror, taking the count from 60 to 61.)
// (#6621 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 61 to 62.)
Expand Down Expand Up @@ -52,14 +53,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 64 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 65 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name);
const primary = names.filter((n) => n.startsWith("loopover_"));
const legacy = names.filter((n) => n.startsWith("gittensory_"));
expect(primary.length).toBe(64);
expect(primary.length).toBe(65);
expect(legacy.length).toBe(0);
expect(names.length).toBe(64);
expect(names.length).toBe(65);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -69,11 +70,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 64-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 65-tool count the live server registers", async () => {
const { tools } = await client.listTools();
const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> };
expect(payload.count).toBe(tools.length);
expect(payload.count).toBe(64);
expect(payload.count).toBe(65);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
});
});
Expand Down
60 changes: 60 additions & 0 deletions test/unit/routes-evaluate-escalation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../../src/api/routes";
import { evaluateEscalation } from "../../src/loop-escalation";
import { createTestEnv } from "../helpers/d1";

// #6754: POST /v1/loop/evaluate-escalation — the REST mirror bringing loopover_evaluate_escalation to the same
// parity its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. The route delegates to
// the pure evaluateEscalation (covered by its own unit tests), so these pin the ROUTE contract: the decision is
// returned unmodified for every precedence arm, and a bad body is rejected.
const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" });
const PATH = "/v1/loop/evaluate-escalation";

const post = (env: Env, body: unknown) =>
createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env);

describe("POST /v1/loop/evaluate-escalation (#6754)", () => {
it("returns the escalation decision for a healthy running loop (no escalation)", async () => {
const env = createTestEnv();
const response = await post(env, { runStatus: "running", healthStatus: "healthy" });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ shouldEscalate: false, action: "none", severity: "none" });
});

it("escalates each precedence arm exactly as the pure evaluator does", async () => {
const env = createTestEnv();
// One case per precedence branch: killRequested > error/critical > abandoned/customerFlagged > degraded.
const cases = [
{ runStatus: "running", killRequested: true },
{ runStatus: "error" },
{ runStatus: "running", healthStatus: "critical" },
{ runStatus: "abandoned" },
{ runStatus: "running", customerFlagged: true },
{ runStatus: "running", healthStatus: "degraded" },
{ runStatus: "converged" },
] as const;
for (const body of cases) {
const response = await post(env, body);
expect(response.status, JSON.stringify(body)).toBe(200);
// PARITY: the route must return exactly what the pure evaluator the MCP tool calls returns.
await expect(response.json()).resolves.toEqual(JSON.parse(JSON.stringify(evaluateEscalation(body))));
}
});

it("rejects an invalid or unparseable body with 400", async () => {
const env = createTestEnv();
for (const body of [{}, { runStatus: "bogus" }, { runStatus: "running", healthStatus: "on-fire" }, { runStatus: "running", killRequested: "yes" }]) {
const response = await post(env, body);
expect(response.status, JSON.stringify(body)).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: "invalid_evaluate_escalation_request" });
}
const malformed = await createApp().request(PATH, { method: "POST", headers: apiHeaders(createTestEnv()), body: "{not json" }, createTestEnv());
expect(malformed.status).toBe(400);
});

it("leaks no wallet/hotkey/trust-score terms", async () => {
const env = createTestEnv();
const text = JSON.stringify(await (await post(env, { runStatus: "error" })).json());
expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward/i);
});
});