mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
b0ddcf31e4
* ci: add path-gated AI agent integration tests workflow Runs integration_tests/ai_agent_tests against real LLM providers (Anthropic/OpenAI/Google) only when AI-agent backend code or the tests change, since runs make paid LLM calls. Adds a conftest fixture that skips provider-parametrized cases whose API keys are absent, so CI exercises only the providers it has secrets for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: add path-gated ai_evals global-mode smoke workflow Runs the global AI chat eval (global-test1) across one cheap model per provider (Anthropic/OpenAI/Google/DeepSeek) only when the eval harness or copilot chat code change, since runs make paid LLM calls. Builds Windmill CE from source as the AI proxy; global tools/drafts run in the Vitest bridge. Gates on the deterministic draft pipeline (run succeeded + produced a draft + used write_script), not the variable LLM judge score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: run AI smokes on PR ready-for-review instead of every push Switch the pull_request trigger from `synchronize` (every commit) to `ready_for_review`, with a job guard skipping draft PRs, so the paid LLM runs only fire when a PR is marked ready to merge (plus push-to-main and manual dispatch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai_evals): lazily load cli mode so non-cli evals skip the cli toolchain The entrypoint eagerly imported modes/cli, which pulls the wmill CLI guidance modules and their JSR deps (@cliffy/*). Global/flow/script/app runs then crashed with "Cannot find module '@cliffy/ansi/colors'" when the cli workspace deps were not installed. Import createCliModeRunner dynamically inside runCliBenchmark instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai_agent): raise low max_completion_tokens to OpenAI's 16 minimum OpenAI's /v1/responses rejects max_output_tokens < 16 with a 400, failing test_low_max_tokens for openai. 16 still exercises a truncated response. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: run ai_evals workflow on Node 22 for the frontend undici 8.x dep The Vitest bridge loads frontend/node_modules/undici@8.x, which requires Node >=22.19; Node 20 failed with "webidl.util.markAsUncloneable is not a function" when loading vitest.config.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai_evals): run frontend evals autonomously + give global-test1 more turns Frontend evals (flow/script/app/global) ran the production chat prompt, which assumes an interactive human — so cheaper models burned their turn budget asking for confirmation, waiting for approval, or presenting a plan, sometimes hitting maxTurns without producing a draft. Append a shared autonomy note in baseEvalRunner (the path all frontend modes share, mirroring cli mode): act directly on clear requests; only ask on genuinely ambiguous ones (preserving the askUserQuestion cases). Also raise global-test1's maxTurns 8 -> 10 so a model that over-explores still converges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(ai_evals): watch draft/prompt deps outside copilot/ The global eval runs production frontend code in-process, so the smoke's behavior depends on files outside frontend/src/lib/components/copilot/**: the draft model (userDraft.svelte.ts, userDraftDbSyncer.svelte.ts), script inference (infer.ts), and the chat system prompts ($system_prompts -> system_prompts/auto-generated). Add them to both push and PR path filters so a change there actually triggers the smoke that gates on draft production. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: skip direct provider tests without credentials * feat: add ai evals skip judge flag * fix: simplify ai evals ci gate * fix: simplify ai evals smoke gate * fix: handle ai eval workflow triggers --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { Command, InvalidArgumentError } from "commander";
|
|
import { loadCases, loadSelectedCases } from "../core/cases";
|
|
import {
|
|
BACKEND_VALIDATION_MODES,
|
|
parseBackendValidationMode,
|
|
} from "../core/backendValidation";
|
|
import {
|
|
EVAL_MODELS,
|
|
type EvalModelSpec,
|
|
formatRunModelLabel,
|
|
getCliEvalModel,
|
|
getEvalModelHelpText,
|
|
resolveEvalModel,
|
|
} from "../core/models";
|
|
import {
|
|
appendHistoryRecord,
|
|
buildRunResult,
|
|
formatRunSummary,
|
|
resolveRunOutputPath,
|
|
writeRunArtifacts,
|
|
writeRunResult,
|
|
} from "../core/results";
|
|
import { runSuite } from "../core/runSuite";
|
|
import { EVAL_MODES, type EvalMode } from "../core/types";
|
|
import { DEFAULT_JUDGE_MODEL } from "../core/judge";
|
|
// createCliModeRunner is imported lazily in runCliBenchmark so the non-cli modes
|
|
// (global/flow/script/app) don't pull in the wmill CLI toolchain and its JSR deps
|
|
// (e.g. @cliffy/*) just to load this entrypoint.
|
|
import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime";
|
|
import { resolveWindmillBackendSettings } from "../core/windmillBackendSettings";
|
|
import { assertWindmillBackendReachable } from "../adapters/frontend/windmillBackend";
|
|
|
|
async function main() {
|
|
const program = new Command()
|
|
.name("bun run cli --")
|
|
.description(
|
|
"Run AI eval cases against the current production prompts and guidance",
|
|
)
|
|
.showHelpAfterError()
|
|
.showSuggestionAfterError()
|
|
.addHelpText(
|
|
"after",
|
|
[
|
|
"",
|
|
"Examples:",
|
|
" bun run cli -- models",
|
|
" bun run cli -- cases",
|
|
" bun run cli -- cases flow",
|
|
" bun run cli -- run flow",
|
|
" bun run cli -- run flow --model 4o",
|
|
" bun run cli -- run flow --models haiku,opus,4o",
|
|
" bun run cli -- run flow flow-test0-sum-two-numbers --verbose",
|
|
" bun run cli -- run flow --record",
|
|
" bun run cli -- run flow --backend-validation preview",
|
|
" bun run cli -- run flow flow-test5-simple-modification --runs 3",
|
|
" bun run cli -- run global global-test1-script-create",
|
|
" bun run cli -- run cli bun-hello-script",
|
|
"",
|
|
"Models:",
|
|
getEvalModelHelpText(),
|
|
].join("\n"),
|
|
);
|
|
|
|
program
|
|
.command("models")
|
|
.description("List available model aliases")
|
|
.action(() => {
|
|
handleModels();
|
|
});
|
|
|
|
program
|
|
.command("cases")
|
|
.description("List available cases")
|
|
.argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode)
|
|
.action(async (mode?: EvalMode) => {
|
|
await handleCases(mode);
|
|
});
|
|
|
|
program
|
|
.command("run")
|
|
.description("Run one benchmark mode")
|
|
.argument("<mode>", "cli, flow, script, app, or global", parseMode)
|
|
.argument("[caseIds...]", "specific case ids to run")
|
|
.option(
|
|
"--runs <n>",
|
|
"number of attempts per case",
|
|
parsePositiveInteger,
|
|
1,
|
|
)
|
|
.option("--output <path>", "write the result JSON to this path")
|
|
.option(
|
|
"--model <name>",
|
|
`model alias (${EVAL_MODELS.map((entry) => entry.id).join(", ")})`,
|
|
)
|
|
.option(
|
|
"--models <names>",
|
|
"comma-separated model aliases to run sequentially",
|
|
)
|
|
.option("--verbose", "stream assistant output during frontend runs")
|
|
.option("--skip-judge", "skip LLM judge scoring for this run")
|
|
.option(
|
|
"--execution-only",
|
|
"only require the model/proxy/frontend loop to complete",
|
|
)
|
|
.option(
|
|
"--record",
|
|
"append a compact summary line to ai_evals/history/<mode>.jsonl",
|
|
)
|
|
.option(
|
|
"--backend-validation <mode>",
|
|
`backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`,
|
|
)
|
|
.action(
|
|
async (
|
|
mode: EvalMode,
|
|
caseIds: string[],
|
|
options: {
|
|
runs: number;
|
|
output?: string;
|
|
model?: string;
|
|
models?: string;
|
|
verbose?: boolean;
|
|
skipJudge?: boolean;
|
|
executionOnly?: boolean;
|
|
record?: boolean;
|
|
backendValidation?: string;
|
|
},
|
|
) => {
|
|
await handleRun({
|
|
mode,
|
|
caseIds,
|
|
runs: options.runs,
|
|
outputPath: options.output,
|
|
model: options.model,
|
|
models: options.models,
|
|
verbose: options.verbose ?? false,
|
|
skipJudge: options.skipJudge ?? false,
|
|
executionOnly: options.executionOnly ?? false,
|
|
record: options.record ?? false,
|
|
backendValidation: options.backendValidation,
|
|
});
|
|
},
|
|
);
|
|
|
|
await program.parseAsync(process.argv);
|
|
}
|
|
|
|
async function handleCases(mode?: EvalMode) {
|
|
const modes = mode ? [mode] : [...EVAL_MODES];
|
|
|
|
for (const entry of modes) {
|
|
const cases = await loadCases(entry);
|
|
process.stdout.write(`${entry} (${cases.length})\n`);
|
|
for (const evalCase of cases) {
|
|
process.stdout.write(`- ${evalCase.id}\n`);
|
|
}
|
|
process.stdout.write("\n");
|
|
}
|
|
}
|
|
|
|
function handleModels() {
|
|
process.stdout.write("Available models\n");
|
|
for (const model of EVAL_MODELS) {
|
|
const supports = [
|
|
...(model.frontend ? ["flow", "script", "app", "global"] : []),
|
|
...(model.cli ? ["cli"] : []),
|
|
];
|
|
const aliases = [
|
|
model.id,
|
|
...model.aliases.filter((alias) => alias !== model.id),
|
|
];
|
|
process.stdout.write(`- ${model.id}: ${model.label}\n`);
|
|
process.stdout.write(` aliases: ${aliases.join(", ")}\n`);
|
|
process.stdout.write(` modes: ${supports.join(", ")}\n`);
|
|
}
|
|
process.stdout.write(`\nJudge model: ${DEFAULT_JUDGE_MODEL}\n`);
|
|
}
|
|
|
|
async function handleRun(input: {
|
|
mode: EvalMode;
|
|
caseIds: string[];
|
|
runs: number;
|
|
outputPath?: string;
|
|
model?: string;
|
|
models?: string;
|
|
verbose: boolean;
|
|
skipJudge: boolean;
|
|
executionOnly: boolean;
|
|
record: boolean;
|
|
backendValidation?: string;
|
|
}) {
|
|
if (input.record && input.caseIds.length > 0) {
|
|
throw new Error(
|
|
"--record only supports full-suite runs; omit case ids to record history",
|
|
);
|
|
}
|
|
if (input.model && input.models) {
|
|
throw new Error("Use either --model or --models, not both");
|
|
}
|
|
|
|
const selectedCases = await loadSelectedCases(input.mode, input.caseIds);
|
|
const models = resolveRequestedModels(input.mode, input.model, input.models);
|
|
const backendValidation = parseBackendValidationMode(
|
|
input.backendValidation ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION,
|
|
);
|
|
if (input.outputPath && models.length > 1) {
|
|
throw new Error("--output only supports a single model run");
|
|
}
|
|
if (
|
|
backendValidation !== "off" &&
|
|
input.mode !== "flow" &&
|
|
input.mode !== "script"
|
|
) {
|
|
throw new Error(
|
|
"--backend-validation currently supports only flow and script modes",
|
|
);
|
|
}
|
|
if (input.mode !== "cli") {
|
|
await assertWindmillBackendReachable(resolveWindmillBackendSettings());
|
|
}
|
|
|
|
const summaries: Array<{
|
|
label: string;
|
|
passRate: number;
|
|
averagePassedDurationMs: number | null;
|
|
}> = [];
|
|
|
|
for (const [index, model] of models.entries()) {
|
|
const runModel = formatRunModelLabel(input.mode, model);
|
|
if (models.length > 1) {
|
|
process.stdout.write(
|
|
`${index > 0 ? "\n" : ""}=== ${input.mode} ${model.id} (${runModel}) ===\n`,
|
|
);
|
|
}
|
|
process.stderr.write(`Starting ${input.mode} benchmark...\n`);
|
|
|
|
const result =
|
|
input.mode === "cli"
|
|
? await runCliBenchmark(
|
|
selectedCases,
|
|
input.runs,
|
|
getCliEvalModel(model),
|
|
runModel,
|
|
input.skipJudge,
|
|
input.executionOnly,
|
|
)
|
|
: await runFrontendBenchmarkAdapter({
|
|
mode: input.mode,
|
|
caseIds: input.caseIds,
|
|
runs: input.runs,
|
|
model: model.id,
|
|
verbose: input.verbose,
|
|
skipJudge: input.skipJudge,
|
|
executionOnly: input.executionOnly,
|
|
backendValidation,
|
|
});
|
|
|
|
const resolvedOutputPath =
|
|
models.length === 1
|
|
? resolveRunOutputPath(input.mode, input.outputPath)
|
|
: resolveRunOutputPath(input.mode);
|
|
const artifactsPath = await writeRunArtifacts(result, resolvedOutputPath);
|
|
const resultPath = await writeRunResult(result, resolvedOutputPath);
|
|
const historyPath = input.record ? await appendHistoryRecord(result) : null;
|
|
process.stdout.write(`${formatRunSummary(result)}\n`);
|
|
process.stdout.write(`Saved: ${resultPath}\n`);
|
|
if (artifactsPath) {
|
|
process.stdout.write(`Artifacts: ${artifactsPath}\n`);
|
|
}
|
|
if (historyPath) {
|
|
process.stdout.write(`Recorded: ${historyPath}\n`);
|
|
}
|
|
|
|
summaries.push({
|
|
label: `${model.id} (${runModel})`,
|
|
passRate: result.passRate,
|
|
averagePassedDurationMs: result.averagePassedDurationMs ?? null,
|
|
});
|
|
}
|
|
|
|
if (summaries.length > 1) {
|
|
process.stdout.write("\nModel summary\n");
|
|
for (const summary of summaries) {
|
|
process.stdout.write(
|
|
`- ${summary.label}: ${formatPercent(summary.passRate)} | passed avg ${formatNullableDuration(summary.averagePassedDurationMs)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runCliBenchmark(
|
|
cases: Awaited<ReturnType<typeof loadSelectedCases>>,
|
|
runs: number,
|
|
model: ReturnType<typeof getCliEvalModel>,
|
|
runModel: string,
|
|
skipJudge: boolean,
|
|
executionOnly: boolean,
|
|
) {
|
|
const { createCliModeRunner } = await import("../modes/cli");
|
|
const judgeModel = skipJudge || executionOnly ? null : DEFAULT_JUDGE_MODEL;
|
|
const caseResults = await runSuite({
|
|
modeRunner: createCliModeRunner(model),
|
|
cases,
|
|
runs,
|
|
runModel,
|
|
judgeModel,
|
|
executionOnly,
|
|
});
|
|
|
|
return buildRunResult({
|
|
mode: "cli",
|
|
runs,
|
|
runModel,
|
|
judgeModel,
|
|
caseResults,
|
|
});
|
|
}
|
|
|
|
function parseMode(value: string): EvalMode {
|
|
if (EVAL_MODES.includes(value as EvalMode)) {
|
|
return value as EvalMode;
|
|
}
|
|
throw new InvalidArgumentError(
|
|
`mode must be one of: ${EVAL_MODES.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
function parseOptionalMode(value: string | undefined): EvalMode | undefined {
|
|
return value ? parseMode(value) : undefined;
|
|
}
|
|
|
|
function parsePositiveInteger(value: string): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
throw new InvalidArgumentError("must be a positive integer");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function resolveRequestedModels(
|
|
mode: EvalMode,
|
|
singleModel?: string,
|
|
multipleModels?: string,
|
|
): EvalModelSpec[] {
|
|
if (!multipleModels) {
|
|
return [resolveEvalModel(mode, singleModel)];
|
|
}
|
|
|
|
const aliases = multipleModels
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
if (aliases.length === 0) {
|
|
throw new Error("--models requires at least one model alias");
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const models: EvalModelSpec[] = [];
|
|
for (const alias of aliases) {
|
|
const model = resolveEvalModel(mode, alias);
|
|
if (seen.has(model.id)) {
|
|
continue;
|
|
}
|
|
seen.add(model.id);
|
|
models.push(model);
|
|
}
|
|
return models;
|
|
}
|
|
|
|
function formatPercent(value: number): string {
|
|
return `${(value * 100).toFixed(1)}%`;
|
|
}
|
|
|
|
function formatNullableDuration(value: number | null): string {
|
|
return value === null ? "n/a" : `${Math.round(value)}ms`;
|
|
}
|
|
|
|
void main().catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`${message}\n`);
|
|
process.exit(1);
|
|
});
|