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
21 changes: 1 addition & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,20 +1 @@
Always follow instructions in `@AGENTS.md`.

<!-- OPENSPEC:START -->
# OpenSpec Instructions

These instructions are for AI assistants working in this project.

Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding

Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines

Keep this managed block so 'openspec update' can refresh the instructions.

<!-- OPENSPEC:END -->
Always follow instructions in `@AGENTS.md`.
3 changes: 2 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentevo",
"version": "0.1.4",
"version": "0.1.6",
"description": "CLI entry point for AgentEvo",
"type": "module",
"repository": {
Expand Down Expand Up @@ -32,6 +32,7 @@
"@agentevo/core": "workspace:*",
"commander": "^12.1.0",
"dotenv": "^16.4.5",
"log-update": "^7.0.1",
"yaml": "^2.6.1"
},
"devDependencies": {
Expand Down
23 changes: 23 additions & 0 deletions apps/cli/src/commands/eval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,32 @@ export function registerEvalCommand(program: Command): Command {
.option("--target <name>", "Override target name from targets.yaml", "default")
.option("--targets <path>", "Path to targets.yaml (overrides discovery)")
.option("--test-id <id>", "Run only the test case with this identifier")
.option(
"--workers <count>",
"Number of parallel workers (default: 1, max: 50). Can also be set per-target in targets.yaml",
(value) => parseInteger(value, 1),
)
.option("--out <path>", "Write results to the specified path")
.option("--format <format>", "Output format: 'jsonl' or 'yaml' (default: jsonl)", "jsonl")
.option("--dry-run", "Use mock provider responses instead of real LLM calls", false)
.option(
"--dry-run-delay <ms>",
"Fixed delay in milliseconds for dry-run mode (overridden by delay range if specified)",
(value) => parseInteger(value, 0),
0,
)
.option(
"--dry-run-delay-min <ms>",
"Minimum delay in milliseconds for dry-run mode (requires --dry-run-delay-max)",
(value) => parseInteger(value, 0),
0,
)
.option(
"--dry-run-delay-max <ms>",
"Maximum delay in milliseconds for dry-run mode (requires --dry-run-delay-min)",
(value) => parseInteger(value, 0),
0,
)
.option(
"--agent-timeout <seconds>",
"Timeout in seconds for provider responses (default: 120)",
Expand Down
24 changes: 14 additions & 10 deletions apps/cli/src/commands/eval/jsonl-writer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Mutex } from "async-mutex";
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { finished } from "node:stream/promises";

export class JsonlWriter {
private readonly stream: ReturnType<typeof createWriteStream>;
private readonly mutex = new Mutex();
private closed = false;

private constructor(stream: ReturnType<typeof createWriteStream>) {
Expand All @@ -18,16 +20,18 @@ export class JsonlWriter {
}

async append(record: unknown): Promise<void> {
if (this.closed) {
throw new Error("Cannot write to closed JSONL writer");
}
const line = `${JSON.stringify(record)}\n`;
if (!this.stream.write(line)) {
await new Promise<void>((resolve, reject) => {
this.stream.once("drain", resolve);
this.stream.once("error", reject);
});
}
await this.mutex.runExclusive(async () => {
if (this.closed) {
throw new Error("Cannot write to closed JSONL writer");
}
const line = `${JSON.stringify(record)}\n`;
if (!this.stream.write(line)) {
await new Promise<void>((resolve, reject) => {
this.stream.once("drain", resolve);
this.stream.once("error", reject);
});
}
});
}

async close(): Promise<void> {
Expand Down
163 changes: 163 additions & 0 deletions apps/cli/src/commands/eval/progress-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import logUpdate from "log-update";

export interface WorkerProgress {
workerId: number;
testId: string;
status: "pending" | "running" | "completed" | "failed";
startedAt?: number;
completedAt?: number;
error?: string;
}

export class ProgressDisplay {
private readonly workers: Map<number, WorkerProgress> = new Map();
private readonly maxWorkers: number;
private totalTests = 0;
private completedTests = 0;
private renderTimer?: NodeJS.Timeout;
private isInteractive: boolean;

constructor(maxWorkers: number) {
this.maxWorkers = maxWorkers;
this.isInteractive = process.stderr.isTTY && !process.env.CI;
}

start(): void {
if (this.isInteractive) {
// Print initial empty line for visual separation
console.log("");
}
}

setTotalTests(count: number): void {
this.totalTests = count;
}

updateWorker(progress: WorkerProgress): void {
this.workers.set(progress.workerId, progress);

if (progress.status === "completed" || progress.status === "failed") {
this.completedTests++;
}

if (this.isInteractive) {
this.scheduleRender();
} else {
// In non-interactive mode, just print completion events
if (progress.status === "completed") {
console.log(`✓ Test ${progress.testId} completed`);
} else if (progress.status === "failed") {
console.log(`✗ Test ${progress.testId} failed${progress.error ? `: ${progress.error}` : ""}`);
}
}
}

private scheduleRender(): void {
if (this.renderTimer) {
return;
}
this.renderTimer = setTimeout(() => {
this.renderTimer = undefined;
this.render();
}, 100); // Debounce renders to 100ms
}

private render(): void {
if (!this.isInteractive) {
return;
}

const lines: string[] = [];

// Empty line above progress display
//lines.push("");

// Header with overall progress
const progressBar = this.buildProgressBar(this.completedTests, this.totalTests);
lines.push(`${progressBar} ${this.completedTests}/${this.totalTests} tests`);

// Empty line between progress and workers
lines.push("");

// Worker status lines
const sortedWorkers = Array.from(this.workers.values()).sort((a, b) => a.workerId - b.workerId);
for (const worker of sortedWorkers) {
const line = this.formatWorkerLine(worker);
lines.push(line);
}

// Use log-update to handle all cursor positioning
logUpdate(lines.join("\n"));
}

private formatWorkerLine(worker: WorkerProgress): string {
const workerLabel = `Worker ${worker.workerId}`.padEnd(10);
const statusIcon = this.getStatusIcon(worker.status);
const elapsed = worker.startedAt ? this.formatElapsed(Date.now() - worker.startedAt) : "";
const timeLabel = elapsed ? ` (${elapsed})` : "";

let testLabel = worker.testId;
if (testLabel.length > 50) {
testLabel = testLabel.substring(0, 47) + "...";
}

return `${workerLabel} ${statusIcon} ${testLabel}${timeLabel}`;
}

private getStatusIcon(status: WorkerProgress["status"]): string {
switch (status) {
case "pending":
return "⏳";
case "running":
return "🔄";
case "completed":
return "✅";
case "failed":
return "❌";
default:
return " ";
}
}

private formatElapsed(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}

private buildProgressBar(current: number, total: number): string {
if (total === 0) {
return "[ ]";
}

const width = 20;
const filled = Math.floor((current / total) * width);
const empty = width - filled;
const bar = "█".repeat(filled) + "░".repeat(empty);
const percentage = Math.floor((current / total) * 100);

return `[${bar}] ${percentage}%`;
}

finish(): void {
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}

if (this.isInteractive) {
this.render();
logUpdate.done(); // Persist the final output
}
}

clear(): void {
if (this.isInteractive) {
logUpdate.clear();
}
}
}
Loading