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
10 changes: 9 additions & 1 deletion agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@ You are Loopworks' neutral stage orchestrator.
Read durable run state and delegate exactly one stage to the matching declared
subagent. Planning belongs to `planner`; approved test writing belongs to
`test-writer`; development belongs to `implementer`. Stage subagents have
isolated sandboxes and communicate only with typed artifacts.
isolated sandboxes and communicate only with typed artifacts; code review belongs to `validation-reviewer`
and may begin only after passing
deterministic validation and complete validation-owned screenshot evidence.

Always begin with `read_run_stage_context`. After planner delegation, call
`record_plan_artifact`; after test-writer delegation, call
`apply_test_writing_result`; after implementer delegation, call
`apply_implementation_result`. A subagent response alone never changes durable
state.

After validation-reviewer delegation, call `apply_validation_review_result`.
Only that root tool may apply the review recommendation: `commit` advances;
`development` requeues development, validation, and review; `test-writing`
requeues test writing plus every downstream reviewed stage. Never let a sibling
write durable state or apply its own route.

Never infer approval from a prompt. Test writing requires a persisted
`plan-review` approval bound to the exact run, plan row, and plan digest. Durable
artifact persistence and stage transitions belong to deterministic control-plane
Expand Down
14 changes: 14 additions & 0 deletions agent/subagents/validation-reviewer/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineAgent } from "eve";

import { validationReviewResultSchema } from "../../validation-review-agent";

export default defineAgent({
description:
"Review deterministic validation, implementation, test-plan, and screenshot evidence and recommend the next development-loop stage.",
model: "openai/gpt-5.6-terra",
modelContextWindowTokens: 400_000,
modelOptions: {
providerOptions: { openai: { reasoningEffort: "xhigh" } },
},
outputSchema: validationReviewResultSchema,
});
15 changes: 15 additions & 0 deletions agent/subagents/validation-reviewer/instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Loopworks Validation Reviewer

Review only the exact durable handoff loaded by `read_validation_review_context`.
Inspect the bounded production patch, functional test steps, passing validation
results, responsive screenshots, and commit-pinned repository files. Every
finding and recommendation must cite the typed evidence ids returned by tools.

Use `commit` only when no blocker or high finding remains. Use `development`
for implementation defects and `test-writing` for missing or incorrect tests,
fixtures, or acceptance coverage. Emit the typed result through
`emit_validation_review_result`.

Do not run validation, edit source, transition durable state, mutate GitHub,
use network access, or include raw prompts, command output, patch bodies,
screenshot bytes, credentials, or secrets in the result.
209 changes: 209 additions & 0 deletions agent/subagents/validation-reviewer/lib/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { and, eq } from "drizzle-orm";

import { db } from "@/db/client";
import { agentPlans, approvals, artifacts, loopRuns, runSteps } from "@/db/schema";
import type { ScreenshotEvidence } from "@/lib/loops/screenshot-evidence";
import {
assertScreenshotEvidenceCoverage,
classifyUiAffectingChange,
computeScreenshotEvidenceDigest,
screenshotBrowserTests,
screenshotEvidenceSchema,
} from "@/lib/loops/screenshot-evidence";
import type { ValidationReportV1 } from "@/lib/loops/validation-report";
import { validationReportV1Schema } from "@/lib/loops/validation-report";
import {
computeImplementationDigest,
type ImplementationResult,
implementationResultSchema,
} from "../../../implementation-agent";
import {
computePlanningArtifactDigest,
type PlanningAgentOutput,
planningAgentOutputSchema,
} from "../../../planning-agent";
import {
computeTestPlanDigest,
type TestWritingAgentOutput,
testPlanArtifactSchema,
} from "../../../test-writing-agent";
import { computeValidationReviewDigest } from "../../../validation-review-agent";
import { createValidationReviewFixtureContext } from "../../../validation-review-fixture";
import { resolveValidationReviewerFixtureMode } from "./fixture-mode";

export type ValidationReviewContext = {
run: { id: string; currentStage: string; status: string };
validationStep: { id: string; status: string };
reviewStep: { id: string; status: string; attempt: number };
planStatus: string;
approvalStatus: string;
plan: PlanningAgentOutput;
testPlan: TestWritingAgentOutput["testPlan"];
implementationResult: ImplementationResult;
validationReport: ValidationReportV1;
screenshotEvidence: ScreenshotEvidence;
testPlanArtifactSha256: string | null;
implementationArtifactSha256: string | null;
validationArtifactSha256: string | null;
screenshotArtifactSha256: string | null;
};

export function validateValidationReviewContext(
input: ValidationReviewContext,
): ValidationReviewContext {
const plan = planningAgentOutputSchema.parse(input.plan);
const testPlan = testPlanArtifactSchema.parse(input.testPlan);
const implementation = implementationResultSchema.parse(input.implementationResult);
const report = validationReportV1Schema.parse(input.validationReport);
const screenshots = screenshotEvidenceSchema.parse(input.screenshotEvidence);
if (
input.run.currentStage !== "code-review" ||
input.run.status !== "running" ||
input.validationStep.status !== "succeeded" ||
!["queued", "running"].includes(input.reviewStep.status) ||
input.planStatus !== "approved" ||
input.approvalStatus !== "approved"
) {
throw new Error("Validation review requires a running code-review stage after validation.");
}
if (
report.overallOutcome !== "pass" ||
report.results.length === 0 ||
report.results.some(
(result) => result.outcome !== "pass" || (result.required && result.outcome !== "pass"),
)
) {
throw new Error("Validation review requires complete passing deterministic validation.");
}
if (
!plan.repositoryRevision ||
computePlanningArtifactDigest(plan) !== plan.identity.sha256 ||
testPlan.plan.id !== plan.identity.id ||
testPlan.plan.sha256 !== plan.identity.sha256 ||
testPlan.plan.repositoryFullName !== plan.issue.repositoryFullName ||
testPlan.plan.commitSha !== plan.repositoryRevision.commitSha ||
implementation.binding.planId !== plan.identity.id ||
implementation.binding.planSha256 !== plan.identity.sha256 ||
implementation.binding.testPlanSha256 !== computeTestPlanDigest(testPlan) ||
implementation.binding.testPatchSha256 !== testPlan.patch.sha256 ||
implementation.binding.fixturesSha256 !== computeImplementationDigest(testPlan.fixtures) ||
implementation.binding.repositoryFullName !== plan.issue.repositoryFullName ||
implementation.binding.commitSha !== plan.repositoryRevision.commitSha ||
screenshots.binding.repositoryFullName !== plan.issue.repositoryFullName ||
screenshots.binding.commitSha !== plan.repositoryRevision.commitSha ||
screenshots.binding.testPlanSha256 !== computeTestPlanDigest(testPlan) ||
screenshots.binding.productionPatchSha256 !== implementation.patch.sha256
) {
throw new Error("Validation review context artifacts are not bound to the same handoff.");
}
const expectedCriteria = plan.issue.acceptanceCriteria.map((text, index) => ({
id: `ac-${index + 1}`,
text,
}));
if (JSON.stringify(testPlan.acceptanceCriteria) !== JSON.stringify(expectedCriteria)) {
throw new Error("Validation review test plan does not match the approved acceptance criteria.");
}
assertScreenshotEvidenceCoverage(screenshots, {
uiAffecting: classifyUiAffectingChange({
productionPaths: implementation.patch.paths,
tests: testPlan.tests,
}),
browserTestIds: screenshotBrowserTests(testPlan.tests).map(({ id }) => id),
});
if (
input.testPlanArtifactSha256 !== computeTestPlanDigest(testPlan) ||
input.implementationArtifactSha256 !== computeImplementationDigest(implementation) ||
input.validationArtifactSha256 !== computeValidationReviewDigest(report) ||
input.screenshotArtifactSha256 !== computeScreenshotEvidenceDigest(screenshots)
) {
throw new Error("Validation review context artifacts are stale.");
}
return input;
}

export async function loadValidationReviewContext(runId: string): Promise<ValidationReviewContext> {
if (resolveValidationReviewerFixtureMode().enabled) return createValidationReviewFixtureContext();
const [run] = await db.select().from(loopRuns).where(eq(loopRuns.id, runId));
if (!run) throw new Error(`Run ${runId} was not found.`);
const [plans, planApprovals, steps, rows] = await Promise.all([
db.select().from(agentPlans).where(eq(agentPlans.runId, runId)),
db
.select()
.from(approvals)
.where(and(eq(approvals.runId, runId), eq(approvals.scope, "plan-review"))),
db.select().from(runSteps).where(eq(runSteps.runId, runId)),
db.select().from(artifacts).where(eq(artifacts.runId, runId)),
]);
if (plans.length !== 1 || planApprovals.length !== 1) {
throw new Error("Validation review requires exactly one plan and plan approval.");
}
const validationSteps = steps.filter(({ stage }) => stage === "validation");
const reviewSteps = steps.filter(({ stage }) => stage === "code-review");
if (validationSteps.length !== 1 || reviewSteps.length !== 1) {
throw new Error("Validation review requires exact validation and code-review steps.");
}
const testPlanRows = rows.filter(({ type }) => type === "test_plan");
const implementationRows = rows.filter(
({ type, metadata }) =>
type === "patch" && metadata?.implementationMetadataKind === "implementation_result",
);
const validationRows = rows.filter(
({ type, stepId }) => type === "validation_report" && stepId === validationSteps[0]?.id,
);
const screenshotRows = rows.filter(
({ type, stepId }) => type === "screenshot" && stepId === validationSteps[0]?.id,
);
if (
testPlanRows.length !== 1 ||
implementationRows.length !== 1 ||
validationRows.length !== 1 ||
screenshotRows.length !== 1
) {
throw new Error(
"Validation review requires exact test, patch, validation, and screenshot artifacts.",
);
}
const [planRow] = plans;
const [approval] = planApprovals;
const [validationStep] = validationSteps;
const [reviewStep] = reviewSteps;
const [testPlanRow] = testPlanRows;
const [implementationRow] = implementationRows;
const [validationRow] = validationRows;
const [screenshotRow] = screenshotRows;
if (
!planRow ||
!approval ||
!validationStep ||
!reviewStep ||
!testPlanRow ||
!implementationRow ||
!validationRow ||
!screenshotRow
) {
throw new Error("Validation review context changed while it was being loaded.");
}
return validateValidationReviewContext({
run: { id: run.id, currentStage: run.currentStage, status: run.status },
validationStep: { id: validationStep.id, status: validationStep.status },
reviewStep: { id: reviewStep.id, status: reviewStep.status, attempt: reviewStep.attempt },
planStatus: planRow.status,
approvalStatus:
approval.metadata?.planId === planRow.id &&
approval.metadata?.planSha256 ===
(planRow.plan as { identity?: { sha256?: string } })?.identity?.sha256
? approval.status
: "mismatched",
plan: planningAgentOutputSchema.parse(planRow.plan),
testPlan: testPlanArtifactSchema.parse(testPlanRow.metadata?.testPlan),
implementationResult: implementationResultSchema.parse(
implementationRow.metadata?.implementationResult,
),
validationReport: validationReportV1Schema.parse(validationRow.metadata?.validationReport),
screenshotEvidence: screenshotEvidenceSchema.parse(screenshotRow.metadata?.screenshotEvidence),
testPlanArtifactSha256: testPlanRow.sha256,
implementationArtifactSha256: implementationRow.sha256,
validationArtifactSha256: validationRow.sha256,
screenshotArtifactSha256: screenshotRow.sha256,
});
}
9 changes: 9 additions & 0 deletions agent/subagents/validation-reviewer/lib/fixture-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { resolveStageFixtureMode, type StageFixtureMode } from "../../../lib/fixture-mode";

export type ValidationReviewerFixtureMode = StageFixtureMode;

export function resolveValidationReviewerFixtureMode(
env: Partial<NodeJS.ProcessEnv> = process.env,
): ValidationReviewerFixtureMode {
return resolveStageFixtureMode("LOOPWORKS_EVE_VALIDATION_REVIEWER_FIXTURE_MODE", env);
}
11 changes: 11 additions & 0 deletions agent/subagents/validation-reviewer/sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defaultBackend, defineSandbox } from "eve/sandbox";

export default defineSandbox({
backend: defaultBackend({
docker: { networkPolicy: "deny-all" },
microsandbox: { networkPolicy: "deny-all" },
vercel: { networkPolicy: "deny-all" },
}),
description:
"Isolated read-only review sandbox with a commit-pinned checkout and deny-all runtime egress.",
});
3 changes: 3 additions & 0 deletions agent/subagents/validation-reviewer/tools/ask_question.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableTool } from "eve/tools";

export default disableTool();
2 changes: 2 additions & 0 deletions agent/subagents/validation-reviewer/tools/bash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { disableTool } from "eve/tools";
export default disableTool();
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineTool } from "eve/tools";

import { validationReviewResultSchema } from "../../../validation-review-agent";

export default defineTool({
description:
"Validate and emit typed evidence-citing review notes and one routing recommendation.",
inputSchema: validationReviewResultSchema,
outputSchema: validationReviewResultSchema,
execute(input) {
return validationReviewResultSchema.parse(input);
},
});
3 changes: 3 additions & 0 deletions agent/subagents/validation-reviewer/tools/glob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableTool } from "eve/tools";

export default disableTool();
3 changes: 3 additions & 0 deletions agent/subagents/validation-reviewer/tools/grep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableTool } from "eve/tools";

export default disableTool();
34 changes: 34 additions & 0 deletions agent/subagents/validation-reviewer/tools/list_repository_files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineTool } from "eve/tools";
import { z } from "zod";
import { assertSafeRepositoryGlob } from "../../../lib/repository-inspection";
import {
listRepositoryFiles,
repositoryListOutputSchema,
} from "../../../lib/repository-inspection-runtime";
import { resolveValidationReviewerFixtureMode } from "../lib/fixture-mode";

const glob = z.string().refine((value) => {
try {
assertSafeRepositoryGlob(value);
return true;
} catch {
return false;
}
}, "Unsafe repository glob.");

export default defineTool({
description: "List bounded regular-file paths from the approved pinned Git commit.",
inputSchema: z.object({ patterns: z.array(glob).min(1).max(5) }),
outputSchema: repositoryListOutputSchema,
async execute({ patterns }, ctx) {
if (resolveValidationReviewerFixtureMode().enabled) {
return {
commitSha: "a".repeat(40),
fixtureMode: true,
paths: ["src/components/review-card.tsx"],
truncated: false,
};
}
return listRepositoryFiles(await ctx.getSandbox(), patterns);
},
});
3 changes: 3 additions & 0 deletions agent/subagents/validation-reviewer/tools/load_skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableTool } from "eve/tools";

export default disableTool();
3 changes: 3 additions & 0 deletions agent/subagents/validation-reviewer/tools/read_file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { disableTool } from "eve/tools";

export default disableTool();
Loading
Loading