From 37b510d7d3a3f287ff141cc47e033de978915168 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 3 Jun 2026 19:53:53 -0400 Subject: [PATCH 1/4] Add local workflow run commands --- CHANGELOG.md | 1 + packages/cli/README.md | 4 + packages/cli/src/cli/bootstrap.test.ts | 3 + packages/cli/src/cli/bootstrap.ts | 2 + .../src/cli/commands/local-workflow.test.ts | 132 ++++ .../cli/src/cli/commands/local-workflow.ts | 578 ++++++++++++++++++ web/content/docs/cli-overview.mdx | 6 +- web/content/docs/reference-cli.mdx | 3 + 8 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/cli/commands/local-workflow.test.ts create mode 100644 packages/cli/src/cli/commands/local-workflow.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2577bef47..f0c5839d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@agent-relay/sdk` agent clients send via `sendMessage({ to })` (`#channel`, `@handle`, or an array of `@handle`s for a group DM), `reply({ messageId })`, and `react({ messageId, emoji })`; every message exposes `messageId`. - `@agent-relay/harnesses` adds `createHuman({ relay, name })` (self-registers a human, returns the live client) and re-exports `defineHarness` plus the harness contract types. - `agent-relay` forwards CLI origin, orchestrator harness, and distinct client identity context to hosted Relaycast so backend telemetry can distinguish CLI/SDK traffic from raw API calls. +- `agent-relay local run|logs|sync` starts executable workflow files on the local machine, stores run metadata and logs under `.agentworkforce/relay/local-runs`, and mirrors the cloud run/logs/sync command shape for laptop-hosted workflows. ### Changed diff --git a/packages/cli/README.md b/packages/cli/README.md index 826bf45dc..4d34c28dc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -27,6 +27,10 @@ agent-relay local up agent-relay local status agent-relay local down +agent-relay local run workflows/my-workflow.ts +agent-relay local logs --follow +agent-relay local sync + agent-relay local agent new claude # spawn + attach agent-relay local agent list agent-relay local agent attach --mode view diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index 48221a694..3906613a8 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -9,6 +9,9 @@ const expectedLeafCommands = [ 'local down', 'local status', 'local metrics', + 'local run', + 'local logs', + 'local sync', 'local tail', 'local agent list', 'local agent spawn', diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index 123974f1a..c3e188012 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -25,6 +25,7 @@ import { registerSetupCommands } from './commands/setup.js'; import { registerCoreCommands, registerCoreMaintenance } from './commands/core.js'; import { registerStatusCommand } from './commands/status.js'; import { registerLocalAgentCommands } from './commands/local-agent.js'; +import { registerLocalWorkflowCommands } from './commands/local-workflow.js'; import { registerCloudCommands } from './commands/cloud.js'; import { registerWorkspaceCommands } from './commands/workspace.js'; import { registerAgentCommands } from './commands/agent.js'; @@ -292,6 +293,7 @@ export function createProgram(options: { name?: string } = {}): Command { const local = program.command('local').description('Manage the local Agent Relay broker and its agents'); registerCoreCommands(local); registerLocalAgentCommands(local); + registerLocalWorkflowCommands(local); registerCoreMaintenance(program); registerStatusCommand(program); diff --git a/packages/cli/src/cli/commands/local-workflow.test.ts b/packages/cli/src/cli/commands/local-workflow.test.ts new file mode 100644 index 000000000..b9438c15d --- /dev/null +++ b/packages/cli/src/cli/commands/local-workflow.test.ts @@ -0,0 +1,132 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerLocalWorkflowCommands, type LocalWorkflowDependencies } from './local-workflow.js'; + +vi.mock('../telemetry/index.js', () => ({ + track: vi.fn(), +})); + +class ExitSignal extends Error { + constructor(public readonly code: number) { + super(`exit:${code}`); + } +} + +const tmpRoots: string[] = []; + +function createHarness() { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'local-workflow-cli-')); + tmpRoots.push(tmpRoot); + + const logs: string[] = []; + const errors: string[] = []; + let stdout = ''; + + const exit = vi.fn((code: number) => { + throw new ExitSignal(code); + }) as unknown as LocalWorkflowDependencies['exit']; + + const deps: Partial = { + cwd: () => tmpRoot, + env: { ...process.env }, + randomRunId: () => 'local_test123', + sleep: async () => undefined, + writeStdout: (text: string) => { + stdout += text; + }, + log: (...args: unknown[]) => { + logs.push(args.join(' ')); + }, + error: (...args: unknown[]) => { + errors.push(args.join(' ')); + }, + exit, + }; + + const program = new Command(); + program.exitOverride(); + registerLocalWorkflowCommands(program, deps); + + return { + program, + tmpRoot, + logs, + errors, + getStdout: () => stdout, + }; +} + +async function waitForRunStatus( + tmpRoot: string, + runId: string, + status: string +): Promise> { + const metadataPath = path.join(tmpRoot, '.agentworkforce', 'relay', 'local-runs', runId, 'run.json'); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (fs.existsSync(metadataPath)) { + const record = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')) as Record; + if (record.status === status) { + return record; + } + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for ${runId} to become ${status}`); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + for (const tmpRoot of tmpRoots.splice(0)) { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + +describe('registerLocalWorkflowCommands', () => { + it('registers local run, logs, and sync commands', () => { + const { program } = createHarness(); + + expect(program.commands.map((command) => command.name())).toEqual(['run', 'logs', 'sync']); + }); + + it('runs a JavaScript workflow in the background and exposes logs and sync state', async () => { + const { program, tmpRoot, logs, getStdout } = createHarness(); + const workflowPath = path.join(tmpRoot, 'workflow.js'); + fs.writeFileSync( + workflowPath, + [ + 'console.log("workflow started", process.env.AGENT_RELAY_LOCAL_RUN_ID);', + 'await new Promise((resolve) => setTimeout(resolve, 25));', + 'console.error("workflow finished");', + ].join('\n'), + 'utf-8' + ); + + await program.parseAsync(['run', 'workflow.js'], { from: 'user' }); + + expect(logs).toContain('Run created: local_test123'); + await waitForRunStatus(tmpRoot, 'local_test123', 'completed'); + + await program.parseAsync(['logs', 'local_test123', '--follow', '--poll-interval', '1'], { from: 'user' }); + expect(getStdout()).toContain('workflow started local_test123'); + expect(getStdout()).toContain('workflow finished'); + + await program.parseAsync(['sync', 'local_test123'], { from: 'user' }); + expect(logs).toContain('Local workflow ran in this checkout; no patch sync is required.'); + }); + + it('rejects local YAML workflows with cloud guidance', async () => { + const { program, tmpRoot } = createHarness(); + fs.writeFileSync(path.join(tmpRoot, 'workflow.yaml'), 'version: "1.0"\n', 'utf-8'); + + await expect(program.parseAsync(['run', 'workflow.yaml'], { from: 'user' })).rejects.toThrow( + 'Local YAML workflow execution is not available' + ); + }); +}); diff --git a/packages/cli/src/cli/commands/local-workflow.ts b/packages/cli/src/cli/commands/local-workflow.ts new file mode 100644 index 000000000..0180def7c --- /dev/null +++ b/packages/cli/src/cli/commands/local-workflow.ts @@ -0,0 +1,578 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { spawn as spawnProcess, type ChildProcess } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { Command, InvalidArgumentError } from 'commander'; +import { build as esbuild } from 'esbuild'; + +import { defaultExit } from '../lib/exit.js'; +import { errorClassName } from '../lib/telemetry-helpers.js'; +import { track } from '../telemetry/index.js'; + +type ExitFn = (code: number) => never; + +type LocalWorkflowFileType = 'ts' | 'js' | 'py' | 'sh' | 'yaml'; +type LocalWorkflowRunStatus = 'starting' | 'running' | 'completed' | 'failed'; + +type LocalWorkflowRunRecord = { + runId: string; + status: LocalWorkflowRunStatus; + workflow: string; + workflowPath: string; + fileType: LocalWorkflowFileType; + cwd: string; + runDir: string; + logPath: string; + metadataPath: string; + command: string; + args: string[]; + monitorPid?: number; + childPid?: number; + exitCode?: number | null; + signal?: string | null; + error?: string; + startedAt: string; + updatedAt: string; + finishedAt?: string; + syncMode: 'in-place'; +}; + +type RunLocalWorkflowOptions = { + fileType?: LocalWorkflowFileType; +}; + +export interface LocalWorkflowDependencies { + cwd: () => string; + env: NodeJS.ProcessEnv; + spawnProcess: typeof spawnProcess; + buildTypeScriptEntrypoint: (entryPoint: string, outfile: string, cwd: string) => Promise; + randomRunId: () => string; + now: () => Date; + sleep: (ms: number) => Promise; + isProcessRunning: (pid: number) => boolean; + writeStdout: (text: string) => void; + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + exit: ExitFn; +} + +const RUN_ID_RE = /^local_[A-Za-z0-9][A-Za-z0-9_-]{0,80}$/; +const TERMINAL_STATUSES = new Set(['completed', 'failed']); + +function withDefaults(overrides: Partial = {}): LocalWorkflowDependencies { + return { + cwd: () => process.cwd(), + env: process.env, + spawnProcess, + buildTypeScriptEntrypoint: async (entryPoint, outfile, cwd) => { + await esbuild({ + entryPoints: [entryPoint], + outfile, + absWorkingDir: cwd, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + packages: 'external', + sourcemap: 'inline', + }); + }, + randomRunId: () => + `local_${new Date().toISOString().replace(/[-:.TZ]/g, '')}_${randomBytes(4).toString('hex')}`, + now: () => new Date(), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + isProcessRunning: (pid) => { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } + }, + writeStdout: (text) => process.stdout.write(text), + log: (...args: unknown[]) => console.log(...args), + error: (...args: unknown[]) => console.error(...args), + exit: defaultExit, + ...overrides, + }; +} + +function parsePositiveInteger(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError('Expected a positive integer.'); + } + return parsed; +} + +function parseNonNegativeInteger(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new InvalidArgumentError('Expected a non-negative integer.'); + } + return parsed; +} + +function parseLocalWorkflowFileType(value: string): LocalWorkflowFileType { + if (value === 'ts' || value === 'js' || value === 'py' || value === 'sh' || value === 'yaml') { + return value; + } + throw new InvalidArgumentError('Expected workflow type to be one of: ts, js, py, sh, yaml'); +} + +function inferLocalWorkflowFileType(filePath: string): LocalWorkflowFileType | null { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.ts': + case '.tsx': + case '.mts': + case '.cts': + return 'ts'; + case '.js': + case '.mjs': + case '.cjs': + return 'js'; + case '.py': + return 'py'; + case '.sh': + return 'sh'; + case '.yaml': + case '.yml': + return 'yaml'; + default: + return null; + } +} + +function toTelemetryWorkflowFileType( + fileType: LocalWorkflowFileType | null +): 'yaml' | 'ts' | 'py' | 'unknown' { + if (fileType === 'yaml' || fileType === 'ts' || fileType === 'py') { + return fileType; + } + return 'unknown'; +} + +function localRunsRoot(cwd: string): string { + return path.join(cwd, '.agentworkforce', 'relay', 'local-runs'); +} + +function validateRunId(runId: string): void { + if (!RUN_ID_RE.test(runId)) { + throw new Error(`Invalid local run id: ${runId}`); + } +} + +function runDirFor(cwd: string, runId: string): string { + validateRunId(runId); + return path.join(localRunsRoot(cwd), runId); +} + +async function writeJsonAtomic(filePath: string, value: unknown): Promise { + const tmpPath = `${filePath}.tmp-${process.pid}`; + await fsp.writeFile(tmpPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8'); + await fsp.rename(tmpPath, filePath); +} + +async function readRunRecord(cwd: string, runId: string): Promise { + const metadataPath = path.join(runDirFor(cwd, runId), 'run.json'); + const raw = await fsp.readFile(metadataPath, 'utf-8').catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + throw new Error(`Local workflow run not found: ${runId}`); + } + throw error; + }); + return JSON.parse(raw) as LocalWorkflowRunRecord; +} + +async function refreshRunRecord( + record: LocalWorkflowRunRecord, + deps: LocalWorkflowDependencies +): Promise { + if ( + TERMINAL_STATUSES.has(record.status) || + !record.monitorPid || + deps.isProcessRunning(record.monitorPid) + ) { + return record; + } + + const now = deps.now().toISOString(); + const next: LocalWorkflowRunRecord = { + ...record, + status: 'failed', + exitCode: record.exitCode ?? null, + signal: record.signal ?? null, + error: record.error ?? 'Workflow monitor exited before recording completion.', + updatedAt: now, + finishedAt: record.finishedAt ?? now, + }; + await writeJsonAtomic(record.metadataPath, next); + return next; +} + +function buildMonitorScript(input: { + metadataPath: string; + command: string; + args: string[]; + cwd: string; + env: Record; +}): string { + return `import fs from 'node:fs'; +import { spawn } from 'node:child_process'; + +const metadataPath = ${JSON.stringify(input.metadataPath)}; +const command = ${JSON.stringify(input.command)}; +const args = ${JSON.stringify(input.args)}; +const cwd = ${JSON.stringify(input.cwd)}; +const extraEnv = ${JSON.stringify(input.env)}; + +function readRecord() { + return JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); +} + +function writeRecord(patch) { + const current = readRecord(); + const next = { ...current, ...patch, updatedAt: new Date().toISOString() }; + const tmpPath = metadataPath + '.tmp-' + process.pid; + fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\\n', 'utf-8'); + fs.renameSync(tmpPath, metadataPath); +} + +writeRecord({ status: 'running', monitorPid: process.pid }); + +const child = spawn(command, args, { + cwd, + env: { ...process.env, ...extraEnv }, + stdio: 'inherit', +}); + +writeRecord({ childPid: child.pid ?? undefined }); + +function stopChild(signal) { + if (!child.killed) { + child.kill(signal); + } +} + +process.on('SIGTERM', () => stopChild('SIGTERM')); +process.on('SIGINT', () => stopChild('SIGINT')); + +child.on('error', (error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + writeRecord({ + status: 'failed', + exitCode: 1, + error: error instanceof Error ? error.message : String(error), + finishedAt: new Date().toISOString(), + }); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + const exitCode = code ?? (signal ? 1 : 0); + writeRecord({ + status: exitCode === 0 ? 'completed' : 'failed', + exitCode, + signal: signal ?? null, + finishedAt: new Date().toISOString(), + }); + process.exit(exitCode); +}); +`; +} + +async function resolveLocalWorkflowCommand( + workflowPath: string, + fileType: LocalWorkflowFileType, + runDir: string, + cwd: string, + deps: LocalWorkflowDependencies +): Promise<{ command: string; args: string[] }> { + if (fileType === 'yaml') { + throw new Error( + 'Local YAML workflow execution is not available in this CLI package yet. Use `agent-relay cloud run ` for YAML workflows, or use an executable TypeScript, JavaScript, Python, or shell workflow file locally.' + ); + } + + if (fileType === 'ts') { + const outfile = path.join(runDir, 'workflow-entry.mjs'); + await deps.buildTypeScriptEntrypoint(workflowPath, outfile, cwd); + return { command: process.execPath, args: [outfile] }; + } + + if (fileType === 'js') { + return { command: process.execPath, args: [workflowPath] }; + } + + if (fileType === 'py') { + return { command: deps.env.PYTHON?.trim() || 'python3', args: [workflowPath] }; + } + + return { command: deps.env.SHELL?.trim() || '/bin/sh', args: [workflowPath] }; +} + +async function runLocalWorkflow( + workflowArg: string, + options: RunLocalWorkflowOptions, + deps: LocalWorkflowDependencies +): Promise { + const cwd = deps.cwd(); + const workflowPath = path.resolve(cwd, workflowArg); + const stat = await fsp.stat(workflowPath).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + throw new Error(`Workflow file not found: ${workflowArg}`); + } + throw error; + }); + if (!stat.isFile()) { + throw new Error(`Workflow path is not a file: ${workflowArg}`); + } + + const fileType = options.fileType ?? inferLocalWorkflowFileType(workflowPath); + if (!fileType) { + throw new Error(`Could not infer workflow type from ${workflowArg}. Use --file-type.`); + } + + const runId = deps.randomRunId(); + const runDir = runDirFor(cwd, runId); + await fsp.mkdir(runDir, { recursive: true }); + + const { command, args } = await resolveLocalWorkflowCommand(workflowPath, fileType, runDir, cwd, deps); + const now = deps.now().toISOString(); + const logPath = path.join(runDir, 'workflow.log'); + const metadataPath = path.join(runDir, 'run.json'); + const runnerPath = path.join(runDir, 'monitor.mjs'); + + const record: LocalWorkflowRunRecord = { + runId, + status: 'starting', + workflow: workflowArg, + workflowPath, + fileType, + cwd, + runDir, + logPath, + metadataPath, + command, + args, + startedAt: now, + updatedAt: now, + syncMode: 'in-place', + }; + + await writeJsonAtomic(metadataPath, record); + await fsp.writeFile(logPath, '', { flag: 'a' }); + await fsp.writeFile( + runnerPath, + buildMonitorScript({ + metadataPath, + command, + args, + cwd, + env: { + AGENT_RELAY_LOCAL_RUN_ID: runId, + AGENT_RELAY_WORKFLOW_FILE: workflowPath, + AGENT_RELAY_WORKFLOW_RUN_DIR: runDir, + }, + }), + 'utf-8' + ); + + const logFd = fs.openSync(logPath, 'a'); + let monitor: ChildProcess; + try { + monitor = deps.spawnProcess(process.execPath, [runnerPath], { + cwd, + detached: true, + stdio: ['ignore', logFd, logFd], + env: deps.env, + }); + } finally { + fs.closeSync(logFd); + } + monitor.unref(); + + const current = await readRunRecord(cwd, runId).catch(() => record); + const next: LocalWorkflowRunRecord = { + ...current, + status: current.status === 'starting' ? 'running' : current.status, + monitorPid: monitor.pid, + updatedAt: deps.now().toISOString(), + }; + await writeJsonAtomic(metadataPath, next); + return next; +} + +async function readLocalRunLogs( + runId: string, + options: { offset: number }, + deps: LocalWorkflowDependencies +): Promise<{ + content: string; + offset: number; + totalSize: number; + done: boolean; + record: LocalWorkflowRunRecord; +}> { + const record = await refreshRunRecord(await readRunRecord(deps.cwd(), runId), deps); + const stat = await fsp.stat(record.logPath).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + return { size: 0 }; + } + throw error; + }); + const totalSize = stat.size; + const offset = Math.min(options.offset, totalSize); + const length = Math.max(0, totalSize - offset); + const handle = await fsp.open(record.logPath, 'r').catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') return null; + throw error; + }); + + let content = ''; + if (handle && length > 0) { + try { + const buffer = Buffer.alloc(length); + const result = await handle.read(buffer, 0, length, offset); + content = buffer.subarray(0, result.bytesRead).toString('utf-8'); + } finally { + await handle.close(); + } + } + + return { + content, + offset: totalSize, + totalSize, + done: TERMINAL_STATUSES.has(record.status), + record, + }; +} + +async function syncLocalRun( + runId: string, + deps: LocalWorkflowDependencies +): Promise<{ runId: string; status: LocalWorkflowRunStatus; hasChanges: false; message: string }> { + const record = await refreshRunRecord(await readRunRecord(deps.cwd(), runId), deps); + if (!TERMINAL_STATUSES.has(record.status)) { + throw new Error(`Run is still ${record.status}. Wait for completion before syncing.`); + } + return { + runId: record.runId, + status: record.status, + hasChanges: false, + message: 'Local workflow ran in this checkout; no patch sync is required.', + }; +} + +export function registerLocalWorkflowCommands( + program: Command, + overrides: Partial = {} +): void { + const deps = withDefaults(overrides); + + program + .command('run') + .description('Run an executable workflow file locally') + .argument('', 'Workflow file path (.ts, .js, .py, or .sh)') + .option('--file-type ', 'Workflow type: ts, js, py, sh, or yaml', parseLocalWorkflowFileType) + .option('--json', 'Print raw JSON response', false) + .action(async (workflow: string, options: { fileType?: LocalWorkflowFileType; json?: boolean }) => { + const started = Date.now(); + let success = false; + let errorClass: string | undefined; + try { + const result = await runLocalWorkflow(workflow, options, deps); + if (options.json) { + deps.log(JSON.stringify(result, null, 2)); + } else { + deps.log(`Run created: ${result.runId}`); + deps.log(`Status: ${result.status}`); + deps.log(`Logs: ${result.logPath}`); + deps.log(`\nView logs: agent-relay local logs ${result.runId} --follow`); + deps.log(`Sync code: agent-relay local sync ${result.runId}`); + } + success = true; + } catch (err) { + errorClass = errorClassName(err); + throw err; + } finally { + track('workflow_run', { + file_type: toTelemetryWorkflowFileType(options.fileType ?? inferLocalWorkflowFileType(workflow)), + is_dry_run: false, + is_resume: false, + is_start_from: false, + is_script: true, + success, + duration_ms: Date.now() - started, + ...(errorClass ? { error_class: errorClass } : {}), + }); + } + }); + + program + .command('logs') + .description('Read local workflow run logs') + .argument('', 'Local workflow run id') + .option('--follow', 'Poll until the run is done', false) + .option('--poll-interval ', 'Polling interval while following', parsePositiveInteger, 2) + .option('--offset ', 'Start reading logs from a byte offset', parseNonNegativeInteger, 0) + .option('--json', 'Print raw JSON responses', false) + .action( + async ( + runId: string, + options: { follow?: boolean; pollInterval?: number; offset?: number; json?: boolean } + ) => { + let offset = options.offset ?? 0; + while (true) { + const result = await readLocalRunLogs(runId, { offset }, deps); + if (options.json) { + deps.log( + JSON.stringify( + { + content: result.content, + offset: result.offset, + totalSize: result.totalSize, + done: result.done, + status: result.record.status, + }, + null, + 2 + ) + ); + } else if (result.content) { + deps.writeStdout(result.content); + } + + offset = result.offset; + if (!options.follow || result.done) { + break; + } + + await deps.sleep((options.pollInterval ?? 2) * 1000); + } + } + ); + + program + .command('sync') + .description('Finalize a local workflow run and report local sync state') + .argument('', 'Local workflow run id') + .option('--dry-run', 'Report sync state without taking action', false) + .option('--json', 'Print raw JSON response', false) + .action(async (runId: string, options: { dryRun?: boolean; json?: boolean }) => { + const result = await syncLocalRun(runId, deps); + if (options.json) { + deps.log(JSON.stringify({ ...result, dryRun: Boolean(options.dryRun) }, null, 2)); + return; + } + deps.log(result.message); + }); +} diff --git a/web/content/docs/cli-overview.mdx b/web/content/docs/cli-overview.mdx index eebef7c0f..545b22ada 100644 --- a/web/content/docs/cli-overview.mdx +++ b/web/content/docs/cli-overview.mdx @@ -102,6 +102,10 @@ agent-relay local up --background agent-relay local status agent-relay local metrics +agent-relay local run workflows/my-workflow.ts +agent-relay local logs --follow +agent-relay local sync + agent-relay local agent spawn codex --name reviewer --channels reviews --task "Review the docs." agent-relay local agent list agent-relay local agent attach reviewer --mode view @@ -110,7 +114,7 @@ agent-relay local agent release reviewer agent-relay local down ``` -The local runtime is optional. It manages a broker process, dashboard, PTY/headless agents, attach modes, logs, and release for CLI agents running on this machine. +The local runtime is optional. It manages a broker process, dashboard, PTY/headless agents, attach modes, workflow logs, and release for CLI agents running on this machine. Local workflow runs execute in the current checkout and keep metadata under `.agentworkforce/relay/local-runs`. ## Composite Status And Maintenance diff --git a/web/content/docs/reference-cli.mdx b/web/content/docs/reference-cli.mdx index 9065be010..9377d946f 100644 --- a/web/content/docs/reference-cli.mdx +++ b/web/content/docs/reference-cli.mdx @@ -133,6 +133,9 @@ The SDK-backed groups are `channel`, `message`, `integration`, and `capabilities | `agent-relay local up [flags]` | Start the local broker and optional dashboard. | | `agent-relay local status [--state-dir ] [--wait-for ]` | Check local broker daemon state. | | `agent-relay local metrics [--agent ]` | Show local broker and agent resource usage. | +| `agent-relay local run [--file-type ]` | Start an executable local workflow file in the background. | +| `agent-relay local logs [--follow]` | Read local workflow run logs. | +| `agent-relay local sync ` | Finalize a local workflow run and report sync state. | | `agent-relay local down [--force] [--all] [--timeout ] [--state-dir ]` | Stop the local broker. | ## Local Agents From 25f5e2fc888f0b09b3603c731a7d2b9d420e9db5 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 4 Jun 2026 07:33:22 -0400 Subject: [PATCH 2/4] Avoid log read file race --- .../cli/src/cli/commands/local-workflow.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/commands/local-workflow.ts b/packages/cli/src/cli/commands/local-workflow.ts index 0180def7c..7b381b59a 100644 --- a/packages/cli/src/cli/commands/local-workflow.ts +++ b/packages/cli/src/cli/commands/local-workflow.ts @@ -420,25 +420,31 @@ async function readLocalRunLogs( record: LocalWorkflowRunRecord; }> { const record = await refreshRunRecord(await readRunRecord(deps.cwd(), runId), deps); - const stat = await fsp.stat(record.logPath).catch((error: unknown) => { + const handle = await fsp.open(record.logPath, 'r').catch((error: unknown) => { const err = error as NodeJS.ErrnoException; if (err.code === 'ENOENT') { - return { size: 0 }; + return null; } throw error; }); - const totalSize = stat.size; - const offset = Math.min(options.offset, totalSize); - const length = Math.max(0, totalSize - offset); - const handle = await fsp.open(record.logPath, 'r').catch((error: unknown) => { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') return null; - throw error; - }); let content = ''; - if (handle && length > 0) { + let totalSize = 0; + if (handle) { try { + const stat = await handle.stat(); + totalSize = stat.size; + const offset = Math.min(options.offset, totalSize); + const length = Math.max(0, totalSize - offset); + if (length === 0) { + return { + content, + offset: totalSize, + totalSize, + done: TERMINAL_STATUSES.has(record.status), + record, + }; + } const buffer = Buffer.alloc(length); const result = await handle.read(buffer, 0, length, offset); content = buffer.subarray(0, result.bytesRead).toString('utf-8'); From 50121540d18ba8d4c3e1b84910193384b40e7478 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 5 Jun 2026 15:30:23 -0400 Subject: [PATCH 3/4] Use Relayflows for local workflow runs --- CHANGELOG.md | 2 + package-lock.json | 1008 ++++++++++++++++- packages/cli/README.md | 2 + packages/cli/package.json | 5 +- .../src/cli/commands/local-workflow.test.ts | 39 +- .../cli/src/cli/commands/local-workflow.ts | 43 +- web/content/docs/cli-overview.mdx | 2 +- web/content/docs/reference-cli.mdx | 2 +- 8 files changed, 1005 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa161fe0..d3e4812d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@agent-relay/harnesses` adds `createHuman({ relay, name })` (self-registers a human, returns the live client) and re-exports `defineHarness` plus the harness contract types. - `agent-relay` forwards CLI origin, orchestrator harness, and distinct client identity context to hosted Relaycast so backend telemetry can distinguish CLI/SDK traffic from raw API calls. - `agent-relay local run|logs|sync` starts executable workflow files on the local machine, stores run metadata and logs under `.agentworkforce/relay/local-runs`, and mirrors the cloud run/logs/sync command shape for laptop-hosted workflows. +- `agent-relay local run` supports Relayflows YAML workflows through the same background logs and sync wrapper used for local script workflows. ### Changed @@ -58,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Root builds now validate the simplified core package set: config, utils, SDK, harness-driver, harnesses, and CLI. - `@agent-relay/sdk` no longer emits client-side analytics or depends on `@agent-relay/telemetry`; SDK/API attribution uses Relaycast origin metadata instead. - `agent-relay` CLI telemetry now posts through the hosted ingestion proxy at `https://i.agentrelay.com` by default. +- `agent-relay local run` delegates YAML, TypeScript, and Python workflow execution to `@relayflows/cli` instead of bundling TypeScript workflows inside the Relay CLI. ### Deprecated diff --git a/package-lock.json b/package-lock.json index 04df27ef9..3a88124e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agent-relay/monorepo", - "version": "8.0.4", + "version": "8.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/monorepo", - "version": "8.0.4", + "version": "8.2.0", "license": "Apache-2.0", "workspaces": [ "packages/*", @@ -1150,6 +1150,42 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/@clack/core": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", + "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], + "license": "MIT", + "dependencies": { + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1569,6 +1605,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1585,6 +1622,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1601,6 +1639,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1617,6 +1656,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1633,6 +1673,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1649,6 +1690,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1665,6 +1707,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1681,6 +1724,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1697,6 +1741,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1713,6 +1758,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1729,6 +1775,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1745,6 +1792,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1761,6 +1809,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1777,6 +1826,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1793,6 +1843,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1809,6 +1860,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1825,6 +1877,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1841,6 +1894,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1857,6 +1911,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1873,6 +1928,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1889,6 +1945,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1905,6 +1962,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1921,6 +1979,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1937,6 +1996,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1953,6 +2013,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1969,6 +2030,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5509,6 +5571,153 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@relayfile/core": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.8.10.tgz", + "integrity": "sha512-fq5607zeDCw7XIB/q1xtNnFqgak9p+MQoqZ4UmreAZ/Fj8ywYv9WYdH5I3/kQX2bMCgQh1KRUjt0FaxPLPJhHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@relayfile/sdk": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@relayfile/sdk/-/sdk-0.8.10.tgz", + "integrity": "sha512-16aFuXOv9vAdBz9uymY9QwdrhaahPGczrvw3U8NUp/9B6foyE2gb7sFPtXupKlY/M2hXYrKJ9RA7dubc5sHndw==", + "license": "MIT", + "dependencies": { + "@relayfile/core": "0.8.10", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@relayflows/browser-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@relayflows/browser-primitive/-/browser-primitive-1.0.1.tgz", + "integrity": "sha512-GHjIWI0i2SdgrE6cxn0xAJWUTwSxeYp4nm6whxSZ1an8DrF5kvGnyokuuaLRV86LGwiLo9hBmOyt+Ml5r7fbBA==", + "dependencies": { + "@agent-relay/sdk": "^8.2.0", + "playwright": "^1.51.1" + }, + "bin": { + "agent-relay-browser-mcp": "dist/mcp-server.js" + } + }, + "node_modules/@relayflows/cli": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@relayflows/cli/-/cli-1.0.1.tgz", + "integrity": "sha512-qc14FODCSP5jCs5GsWTYyJ8qPDtM3Ax4qF8nfPB7ixpoFRk+h3BnlklGSo3Aba5T5oxY+FDRnJzbl/olbwn6EA==", + "dependencies": { + "@relayflows/core": "1.0.1", + "commander": "^12.1.0" + }, + "bin": { + "relayflows": "dist/cli.js" + } + }, + "node_modules/@relayflows/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@relayflows/core/-/core-1.0.1.tgz", + "integrity": "sha512-AqlzwmiirwjmB96tZLQQApjNwHV10OemJPWoHj4YIWe9vR7cD9kT3UHaQ8qTFMrHlfbcM+qujErUBYwtKMM9Rg==", + "dependencies": { + "@agent-relay/cloud": "^8.2.0", + "@agent-relay/config": "^8.2.0", + "@agent-relay/harness-driver": "^8.2.0", + "@agent-relay/harnesses": "^8.2.0", + "@agent-relay/sdk": "^8.2.0", + "@relaycast/sdk": "^1.1.0", + "@relayfile/sdk": "^0.8.0", + "@relayflows/browser-primitive": "1.0.1", + "@relayflows/github-primitive": "1.0.1", + "@relayflows/slack-primitive": "1.0.1", + "@sinclair/typebox": "^0.34.48", + "agent-trajectories": "^0.6.0", + "chalk": "^4.1.2", + "ignore": "^7.0.5", + "listr2": "^10.2.1", + "strip-ansi": "^7.2.0", + "yaml": "^2.7.0", + "zod": "^3.23.8" + } + }, + "node_modules/@relayflows/core/node_modules/@relaycast/sdk": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.2.0.tgz", + "integrity": "sha512-/tBN0Up1X+MMQzyyUq9jNSkoTuPtRWcfno3t5iO8PBCJkE9+b89RY+6SxcmII9+8EjlEgMb3xqYey414wDuwTQ==", + "dependencies": { + "@relaycast/types": "1.2.0", + "zod": "^4.3.6" + } + }, + "node_modules/@relayflows/core/node_modules/@relaycast/sdk/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@relayflows/core/node_modules/@relaycast/types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/types/-/types-1.2.0.tgz", + "integrity": "sha512-ZgnK3VN6RkE2/P+eDRmcr6f4N66yTELT3PHk4ZjIKlmZBL0vgwCZCKC4ZxJrEkcaOPWP4bx3LpajSIKWke6kYA==", + "dependencies": { + "zod": "^4.3.6" + } + }, + "node_modules/@relayflows/core/node_modules/@relaycast/types/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@relayflows/core/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@relayflows/core/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@relayflows/github-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@relayflows/github-primitive/-/github-primitive-1.0.1.tgz", + "integrity": "sha512-kWOXeZUiVrqooOYNGZaRExPU5tW4DlnG5eOvVKhjdQ71a0c8G+8AqC7GqYlXDeHyzjY89Owubg7ii1wakQdDUg==" + }, + "node_modules/@relayflows/slack-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@relayflows/slack-primitive/-/slack-primitive-1.0.1.tgz", + "integrity": "sha512-OWoj7CR8xRGhjmIL4hbkw9NQf8wCdEElfYApDWMt1N18U0646k1Uac8SQ2GB6bEVMS3YygI692D9SMvN8x7kbw==", + "dependencies": { + "@slack/web-api": "^7.16.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -5887,6 +6096,59 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "license": "MIT" + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.16.0.tgz", + "integrity": "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.16.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, "node_modules/@smithy/config-resolver": { "version": "4.5.6", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.5.6.tgz", @@ -7199,6 +7461,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -7751,6 +8019,23 @@ "resolved": "packages/cli", "link": true }, + "node_modules/agent-trajectories": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.6.1.tgz", + "integrity": "sha512-say+2qK1+g7Y2Kt5NphP/+NzPPKjVcemHbFFpK8kY1zK3/X+w+EhBagq2zEcEm5le/AMGdmIJyhHphzfmIgXuQ==", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ahooks": { "version": "3.9.7", "resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.7.tgz", @@ -7830,6 +8115,21 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -7844,7 +8144,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -8012,7 +8311,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/attr-accept": { @@ -8031,6 +8329,43 @@ "integrity": "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ==", "license": "MIT" }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -8260,7 +8595,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -8277,7 +8611,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -8361,6 +8694,80 @@ "license": "MIT", "peer": true }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -8405,7 +8812,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8418,7 +8824,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -8432,7 +8837,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -9316,7 +9720,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -9481,10 +9884,22 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -9531,7 +9946,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9589,6 +10003,7 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -9959,6 +10374,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -10368,6 +10789,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -10382,7 +10823,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -10503,7 +10943,6 @@ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -10723,7 +11162,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10745,7 +11183,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -11349,6 +11786,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-extendable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", @@ -11469,6 +11912,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -12106,6 +12561,94 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lit": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", @@ -12177,6 +12720,135 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -13555,7 +14227,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -13565,7 +14236,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -13574,6 +14244,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -13918,6 +14600,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -14020,6 +14717,15 @@ "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -14052,6 +14758,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -14246,6 +14999,50 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -14447,6 +15244,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -15437,6 +16243,31 @@ "node": ">=4" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -15448,6 +16279,12 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -16002,6 +16839,67 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smol-toml": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", @@ -17703,7 +18601,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -17805,49 +18702,49 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "8.0.4" + "version": "8.2.0" }, "packages/broker-darwin-arm64": { "name": "@agent-relay/broker-darwin-arm64", - "version": "8.0.4", + "version": "8.2.0", "license": "MIT" }, "packages/broker-darwin-x64": { "name": "@agent-relay/broker-darwin-x64", - "version": "8.0.4", + "version": "8.2.0", "license": "MIT" }, "packages/broker-linux-arm64": { "name": "@agent-relay/broker-linux-arm64", - "version": "8.0.4", + "version": "8.2.0", "license": "MIT" }, "packages/broker-linux-x64": { "name": "@agent-relay/broker-linux-x64", - "version": "8.0.4", + "version": "8.2.0", "license": "MIT" }, "packages/broker-win32-x64": { "name": "@agent-relay/broker-win32-x64", - "version": "8.0.4", + "version": "8.2.0", "license": "MIT" }, "packages/cli": { "name": "agent-relay", - "version": "8.0.4", + "version": "8.2.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/cloud": "8.0.4", - "@agent-relay/config": "8.0.4", - "@agent-relay/harness-driver": "8.0.4", - "@agent-relay/sdk": "8.0.4", - "@agent-relay/utils": "8.0.4", + "@agent-relay/cloud": "8.2.0", + "@agent-relay/config": "8.2.0", + "@agent-relay/harness-driver": "8.2.0", + "@agent-relay/sdk": "8.2.0", + "@agent-relay/utils": "8.2.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^2.5.1", + "@relayflows/cli": "^1.0.1", "@xterm/headless": "^6.0.0", "commander": "^12.1.0", "dotenv": "^17.2.3", - "esbuild": "^0.27.2", "posthog-node": "^5.29.2", "zod": "^3.23.8" }, @@ -17855,15 +18752,18 @@ "agent-relay": "dist/cli/index.js", "relay": "dist/cli/index.js" }, + "devDependencies": { + "esbuild": "^0.27.2" + }, "engines": { "node": ">=20.9.0" } }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "8.0.4", + "version": "8.2.0", "dependencies": { - "@agent-relay/config": "8.0.4", + "@agent-relay/config": "8.2.0", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -17879,7 +18779,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "8.0.4", + "version": "8.2.0", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -17892,35 +18792,35 @@ }, "packages/harness-driver": { "name": "@agent-relay/harness-driver", - "version": "8.0.4", + "version": "8.2.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "8.0.4", + "@agent-relay/sdk": "8.2.0", "ws": "^8.18.3", "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "8.0.4", - "@agent-relay/broker-darwin-x64": "8.0.4", - "@agent-relay/broker-linux-arm64": "8.0.4", - "@agent-relay/broker-linux-x64": "8.0.4", - "@agent-relay/broker-win32-x64": "8.0.4" + "@agent-relay/broker-darwin-arm64": "8.2.0", + "@agent-relay/broker-darwin-x64": "8.2.0", + "@agent-relay/broker-linux-arm64": "8.2.0", + "@agent-relay/broker-linux-x64": "8.2.0", + "@agent-relay/broker-win32-x64": "8.2.0" } }, "packages/harnesses": { "name": "@agent-relay/harnesses", - "version": "8.0.4", + "version": "8.2.0", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "8.0.4", - "@agent-relay/sdk": "8.0.4" + "@agent-relay/harness-driver": "8.2.0", + "@agent-relay/sdk": "8.2.0" } }, "packages/policy": { "name": "@agent-relay/policy", - "version": "8.0.4", + "version": "8.2.0", "dependencies": { - "@agent-relay/config": "8.0.4" + "@agent-relay/config": "8.2.0" }, "devDependencies": { "@types/node": "^22.19.3", @@ -17929,7 +18829,7 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "8.0.4", + "version": "8.2.0", "dependencies": { "@relaycast/sdk": "^2.5.1" }, @@ -17939,14 +18839,14 @@ }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "8.0.4", + "version": "8.2.0", "deprecated": "@agent-relay/telemetry is deprecated. Telemetry is now internal to the agent-relay CLI." }, "packages/utils": { "name": "@agent-relay/utils", - "version": "8.0.4", + "version": "8.2.0", "dependencies": { - "@agent-relay/config": "8.0.4", + "@agent-relay/config": "8.2.0", "compare-versions": "^6.1.1" }, "devDependencies": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 4d34c28dc..69a41f32f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -37,6 +37,8 @@ agent-relay local agent attach --mode view agent-relay local agent release ``` +Local workflow runs use Relayflows for YAML, TypeScript, and Python workflow files. + Hosted equivalents live under `agent-relay cloud …`. ## Packages diff --git a/packages/cli/package.json b/packages/cli/package.json index bbb2d7f74..fe24ad0cd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,13 +50,16 @@ "@agent-relay/utils": "8.2.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^2.5.1", + "@relayflows/cli": "^1.0.1", "@xterm/headless": "^6.0.0", "commander": "^12.1.0", "dotenv": "^17.2.3", - "esbuild": "^0.27.2", "posthog-node": "^5.29.2", "zod": "^3.23.8" }, + "devDependencies": { + "esbuild": "^0.27.2" + }, "engines": { "node": ">=20.9.0" }, diff --git a/packages/cli/src/cli/commands/local-workflow.test.ts b/packages/cli/src/cli/commands/local-workflow.test.ts index b9438c15d..f4e7c55c6 100644 --- a/packages/cli/src/cli/commands/local-workflow.test.ts +++ b/packages/cli/src/cli/commands/local-workflow.test.ts @@ -18,7 +18,7 @@ class ExitSignal extends Error { const tmpRoots: string[] = []; -function createHarness() { +function createHarness(overrides: Partial = {}) { const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'local-workflow-cli-')); tmpRoots.push(tmpRoot); @@ -45,6 +45,8 @@ function createHarness() { errors.push(args.join(' ')); }, exit, + resolveRelayflowsCliEntrypoint: () => path.join(tmpRoot, 'relayflows-cli.js'), + ...overrides, }; const program = new Command(); @@ -121,12 +123,35 @@ describe('registerLocalWorkflowCommands', () => { expect(logs).toContain('Local workflow ran in this checkout; no patch sync is required.'); }); - it('rejects local YAML workflows with cloud guidance', async () => { - const { program, tmpRoot } = createHarness(); - fs.writeFileSync(path.join(tmpRoot, 'workflow.yaml'), 'version: "1.0"\n', 'utf-8'); - - await expect(program.parseAsync(['run', 'workflow.yaml'], { from: 'user' })).rejects.toThrow( - 'Local YAML workflow execution is not available' + it.each([ + ['YAML', 'workflow.yaml', 'version: "1.0"\n'], + ['YML', 'workflow.yml', 'version: "1.0"\n'], + ['TypeScript', 'workflow.ts', 'console.log("workflow");\n'], + ['TSX', 'workflow.tsx', 'console.log("workflow");\n'], + ['Python', 'workflow.py', 'print("workflow")\n'], + ])('delegates %s workflow runs to the relayflows CLI', async (_label, fileName, contents) => { + const spawnProcess = vi.fn(() => ({ + pid: 4242, + unref: vi.fn(), + })) as unknown as LocalWorkflowDependencies['spawnProcess']; + const { program, tmpRoot } = createHarness({ spawnProcess }); + const workflowPath = path.join(tmpRoot, fileName); + const relayflowsCliPath = path.join(tmpRoot, 'relayflows-cli.js'); + fs.writeFileSync(workflowPath, contents, 'utf-8'); + + await program.parseAsync(['run', fileName], { from: 'user' }); + + const metadataPath = path.join( + tmpRoot, + '.agentworkforce', + 'relay', + 'local-runs', + 'local_test123', + 'run.json' ); + const record = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')) as Record; + expect(record.command).toBe(process.execPath); + expect(record.args).toEqual([relayflowsCliPath, 'run', workflowPath]); + expect(record.status).toBe('running'); }); }); diff --git a/packages/cli/src/cli/commands/local-workflow.ts b/packages/cli/src/cli/commands/local-workflow.ts index 7b381b59a..84baca021 100644 --- a/packages/cli/src/cli/commands/local-workflow.ts +++ b/packages/cli/src/cli/commands/local-workflow.ts @@ -3,8 +3,8 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; import { spawn as spawnProcess, type ChildProcess } from 'node:child_process'; import { randomBytes } from 'node:crypto'; +import { createRequire } from 'node:module'; import { Command, InvalidArgumentError } from 'commander'; -import { build as esbuild } from 'esbuild'; import { defaultExit } from '../lib/exit.js'; import { errorClassName } from '../lib/telemetry-helpers.js'; @@ -46,7 +46,7 @@ export interface LocalWorkflowDependencies { cwd: () => string; env: NodeJS.ProcessEnv; spawnProcess: typeof spawnProcess; - buildTypeScriptEntrypoint: (entryPoint: string, outfile: string, cwd: string) => Promise; + resolveRelayflowsCliEntrypoint: () => string; randomRunId: () => string; now: () => Date; sleep: (ms: number) => Promise; @@ -59,25 +59,14 @@ export interface LocalWorkflowDependencies { const RUN_ID_RE = /^local_[A-Za-z0-9][A-Za-z0-9_-]{0,80}$/; const TERMINAL_STATUSES = new Set(['completed', 'failed']); +const nodeRequire = createRequire(import.meta.url); function withDefaults(overrides: Partial = {}): LocalWorkflowDependencies { return { cwd: () => process.cwd(), env: process.env, spawnProcess, - buildTypeScriptEntrypoint: async (entryPoint, outfile, cwd) => { - await esbuild({ - entryPoints: [entryPoint], - outfile, - absWorkingDir: cwd, - bundle: true, - format: 'esm', - platform: 'node', - target: 'node20', - packages: 'external', - sourcemap: 'inline', - }); - }, + resolveRelayflowsCliEntrypoint: () => nodeRequire.resolve('@relayflows/cli'), randomRunId: () => `local_${new Date().toISOString().replace(/[-:.TZ]/g, '')}_${randomBytes(4).toString('hex')}`, now: () => new Date(), @@ -288,30 +277,16 @@ child.on('exit', (code, signal) => { async function resolveLocalWorkflowCommand( workflowPath: string, fileType: LocalWorkflowFileType, - runDir: string, - cwd: string, deps: LocalWorkflowDependencies ): Promise<{ command: string; args: string[] }> { - if (fileType === 'yaml') { - throw new Error( - 'Local YAML workflow execution is not available in this CLI package yet. Use `agent-relay cloud run ` for YAML workflows, or use an executable TypeScript, JavaScript, Python, or shell workflow file locally.' - ); - } - - if (fileType === 'ts') { - const outfile = path.join(runDir, 'workflow-entry.mjs'); - await deps.buildTypeScriptEntrypoint(workflowPath, outfile, cwd); - return { command: process.execPath, args: [outfile] }; + if (fileType === 'yaml' || fileType === 'ts' || fileType === 'py') { + return { command: process.execPath, args: [deps.resolveRelayflowsCliEntrypoint(), 'run', workflowPath] }; } if (fileType === 'js') { return { command: process.execPath, args: [workflowPath] }; } - if (fileType === 'py') { - return { command: deps.env.PYTHON?.trim() || 'python3', args: [workflowPath] }; - } - return { command: deps.env.SHELL?.trim() || '/bin/sh', args: [workflowPath] }; } @@ -342,7 +317,7 @@ async function runLocalWorkflow( const runDir = runDirFor(cwd, runId); await fsp.mkdir(runDir, { recursive: true }); - const { command, args } = await resolveLocalWorkflowCommand(workflowPath, fileType, runDir, cwd, deps); + const { command, args } = await resolveLocalWorkflowCommand(workflowPath, fileType, deps); const now = deps.now().toISOString(); const logPath = path.join(runDir, 'workflow.log'); const metadataPath = path.join(runDir, 'run.json'); @@ -486,8 +461,8 @@ export function registerLocalWorkflowCommands( program .command('run') - .description('Run an executable workflow file locally') - .argument('', 'Workflow file path (.ts, .js, .py, or .sh)') + .description('Run a workflow file locally') + .argument('', 'Workflow file path (.yaml, .yml, .ts, .tsx, .py, .js, or .sh)') .option('--file-type ', 'Workflow type: ts, js, py, sh, or yaml', parseLocalWorkflowFileType) .option('--json', 'Print raw JSON response', false) .action(async (workflow: string, options: { fileType?: LocalWorkflowFileType; json?: boolean }) => { diff --git a/web/content/docs/cli-overview.mdx b/web/content/docs/cli-overview.mdx index 545b22ada..087b01316 100644 --- a/web/content/docs/cli-overview.mdx +++ b/web/content/docs/cli-overview.mdx @@ -114,7 +114,7 @@ agent-relay local agent release reviewer agent-relay local down ``` -The local runtime is optional. It manages a broker process, dashboard, PTY/headless agents, attach modes, workflow logs, and release for CLI agents running on this machine. Local workflow runs execute in the current checkout and keep metadata under `.agentworkforce/relay/local-runs`. +The local runtime is optional. It manages a broker process, dashboard, PTY/headless agents, attach modes, workflow logs, and release for CLI agents running on this machine. Local workflow runs execute Relayflows YAML, TypeScript, and Python workflows in the current checkout and keep metadata under `.agentworkforce/relay/local-runs`. ## Composite Status And Maintenance diff --git a/web/content/docs/reference-cli.mdx b/web/content/docs/reference-cli.mdx index 9377d946f..88a149d49 100644 --- a/web/content/docs/reference-cli.mdx +++ b/web/content/docs/reference-cli.mdx @@ -133,7 +133,7 @@ The SDK-backed groups are `channel`, `message`, `integration`, and `capabilities | `agent-relay local up [flags]` | Start the local broker and optional dashboard. | | `agent-relay local status [--state-dir ] [--wait-for ]` | Check local broker daemon state. | | `agent-relay local metrics [--agent ]` | Show local broker and agent resource usage. | -| `agent-relay local run [--file-type ]` | Start an executable local workflow file in the background. | +| `agent-relay local run [--file-type ]` | Start a local Relayflows workflow file in the background. | | `agent-relay local logs [--follow]` | Read local workflow run logs. | | `agent-relay local sync ` | Finalize a local workflow run and report sync state. | | `agent-relay local down [--force] [--all] [--timeout ] [--state-dir ]` | Stop the local broker. | From 431c227474698a8731f3bb441e449ac5a9de10a9 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 9 Jun 2026 05:59:56 -0400 Subject: [PATCH 4/4] Add agent messaging eval suite and reports Introduce a new integration eval suite that exercises agent-to-agent messaging via the broker and scores protocol adherence (message-sent rate, phantom messages, ACK/DONE protocol, wrong-channel replies). Adds a full eval runner, scenarios, deterministic scoring, reporters (JSON + self-contained HTML viewer), a matrix roll-up, unit tests for scoring, and CLI helpers under tests/integration/broker/evals. Adds npm scripts (eval:build, eval:unit, eval:selftest, eval:toolcheck, eval:html, eval, eval:claude, eval:matrix) and gitignore entry for evals-reports. Also adds a Fleet Delivery design doc (specs/fleet-delivery.md), updates CHANGELOG.md, and adjusts integration test config/files (tsconfig, vitest, and broker harness utilities) to align the broker-harness with the current SDK/harness-driver APIs so the evals build/run cleanly. --- .../active/traj_b1jrutolckfb/trajectory.json | 14 +- .gitignore | 3 + CHANGELOG.md | 1 + package.json | 8 + specs/fleet-delivery.md | 189 ++++++++++++++ tests/integration/broker/evals/README.md | 107 ++++++++ tests/integration/broker/evals/eval.test.ts | 45 ++++ tests/integration/broker/evals/report/html.ts | 178 +++++++++++++ .../broker/evals/report/render-cli.ts | 25 ++ .../integration/broker/evals/report/write.ts | 124 +++++++++ tests/integration/broker/evals/runner.ts | 242 ++++++++++++++++++ .../broker/evals/scenarios/01-dm-roundtrip.ts | 65 +++++ .../evals/scenarios/02-channel-reply.ts | 69 +++++ .../broker/evals/scenarios/03-ack-done.ts | 67 +++++ .../broker/evals/scenarios/04-relay-chain.ts | 90 +++++++ .../broker/evals/scenarios/helpers.ts | 40 +++ .../broker/evals/scenarios/index.ts | 39 +++ .../evals/scenarios/r01-incidental-report.ts | 66 +++++ .../evals/scenarios/r02-forget-to-report.ts | 69 +++++ .../evals/scenarios/r03-proactive-handoff.ts | 69 +++++ .../evals/scenarios/r04-channel-vs-dm.ts | 69 +++++ .../integration/broker/evals/scoring/base.ts | 72 ++++++ .../broker/evals/scoring/fixtures.ts | 22 ++ .../broker/evals/scoring/metrics.ts | 48 ++++ .../broker/evals/scoring/metrics.unit.test.ts | 69 +++++ .../broker/evals/scoring/phantom.ts | 177 +++++++++++++ .../broker/evals/scoring/phantom.unit.test.ts | 89 +++++++ .../broker/evals/scoring/protocol.ts | 123 +++++++++ .../evals/scoring/protocol.unit.test.ts | 113 ++++++++ .../broker/evals/scoring/stream-clean.ts | 31 +++ .../broker/evals/scoring/toolcheck.ts | 75 ++++++ .../evals/scoring/toolcheck.unit.test.ts | 56 ++++ tests/integration/broker/evals/selftest.ts | 80 ++++++ .../integration/broker/evals/toolcheck-cli.ts | 61 +++++ tests/integration/broker/evals/tsconfig.json | 30 +++ tests/integration/broker/evals/types.ts | 155 +++++++++++ tests/integration/broker/tsconfig.json | 2 +- .../broker/utils/assert-helpers.ts | 2 +- .../broker/utils/broker-harness.ts | 29 +-- vitest.config.ts | 1 + 40 files changed, 2786 insertions(+), 28 deletions(-) create mode 100644 specs/fleet-delivery.md create mode 100644 tests/integration/broker/evals/README.md create mode 100644 tests/integration/broker/evals/eval.test.ts create mode 100644 tests/integration/broker/evals/report/html.ts create mode 100644 tests/integration/broker/evals/report/render-cli.ts create mode 100644 tests/integration/broker/evals/report/write.ts create mode 100644 tests/integration/broker/evals/runner.ts create mode 100644 tests/integration/broker/evals/scenarios/01-dm-roundtrip.ts create mode 100644 tests/integration/broker/evals/scenarios/02-channel-reply.ts create mode 100644 tests/integration/broker/evals/scenarios/03-ack-done.ts create mode 100644 tests/integration/broker/evals/scenarios/04-relay-chain.ts create mode 100644 tests/integration/broker/evals/scenarios/helpers.ts create mode 100644 tests/integration/broker/evals/scenarios/index.ts create mode 100644 tests/integration/broker/evals/scenarios/r01-incidental-report.ts create mode 100644 tests/integration/broker/evals/scenarios/r02-forget-to-report.ts create mode 100644 tests/integration/broker/evals/scenarios/r03-proactive-handoff.ts create mode 100644 tests/integration/broker/evals/scenarios/r04-channel-vs-dm.ts create mode 100644 tests/integration/broker/evals/scoring/base.ts create mode 100644 tests/integration/broker/evals/scoring/fixtures.ts create mode 100644 tests/integration/broker/evals/scoring/metrics.ts create mode 100644 tests/integration/broker/evals/scoring/metrics.unit.test.ts create mode 100644 tests/integration/broker/evals/scoring/phantom.ts create mode 100644 tests/integration/broker/evals/scoring/phantom.unit.test.ts create mode 100644 tests/integration/broker/evals/scoring/protocol.ts create mode 100644 tests/integration/broker/evals/scoring/protocol.unit.test.ts create mode 100644 tests/integration/broker/evals/scoring/stream-clean.ts create mode 100644 tests/integration/broker/evals/scoring/toolcheck.ts create mode 100644 tests/integration/broker/evals/scoring/toolcheck.unit.test.ts create mode 100644 tests/integration/broker/evals/selftest.ts create mode 100644 tests/integration/broker/evals/toolcheck-cli.ts create mode 100644 tests/integration/broker/evals/tsconfig.json create mode 100644 tests/integration/broker/evals/types.ts diff --git a/.agentworkforce/trajectories/active/traj_b1jrutolckfb/trajectory.json b/.agentworkforce/trajectories/active/traj_b1jrutolckfb/trajectory.json index 8e6d78d7b..f0bfc56bf 100644 --- a/.agentworkforce/trajectories/active/traj_b1jrutolckfb/trajectory.json +++ b/.agentworkforce/trajectories/active/traj_b1jrutolckfb/trajectory.json @@ -43,6 +43,18 @@ "reasoning": "User requested Relaycast request attribution, install/update events, and MCP action-call telemetry while preserving UA-like harness values." }, "significance": "high" + }, + { + "ts": 1780761935387, + "type": "decision", + "content": "Repaired broker-harness.ts to current SDK/harness-driver API and built eval suite on it: Repaired broker-harness.ts to current SDK/harness-driver API and built eval suite on it", + "raw": { + "question": "Repaired broker-harness.ts to current SDK/harness-driver API and built eval suite on it", + "chosen": "Repaired broker-harness.ts to current SDK/harness-driver API and built eval suite on it", + "alternatives": [], + "reasoning": "Broker integration suite was pre-existingly broken: SDK narrowing moved BrokerEvent/HarnessDriverClient/SendMessageInput to @agent-relay/harness-driver and RelayCast to @relaycast/sdk; AgentRelay facade no longer does broker lifecycle. Fixed imports + removed the unused AgentRelay facade from BrokerHarness; added a dedicated evals/tsconfig.json compiling only evals/ + utils/ so eval:build is green without rewriting the still-broken sibling test files." + }, + "significance": "high" } ] } @@ -55,4 +67,4 @@ "startRef": "bd42f4f84f41821e33879618d114941d6eabe835", "endRef": "bd42f4f84f41821e33879618d114941d6eabe835" } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index e6fe0ed66..700ce9981 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ web/.open-next !/workflows/refactor/ !/workflows/relayauth-integration/ !/workflows/cloud-connect/ + +# Eval harness JSON reports (generated per run) +tests/integration/broker/evals-reports/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e4812d2..223ed21d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Agent messaging eval suite (`npm run eval`, `eval:matrix`, `eval:unit`, `eval:selftest`, `eval:toolcheck`) spawns real agent CLIs and scores, from broker events, whether agents actually used the MCP/CLI to message — reporting message-sent rate, phantom-message rate (intent stated in prose but no send), ACK/DONE protocol adherence, and wrong-channel replies. Includes a `realistic` tier (natural-language tasks where the protocol must come from the injected onboarding) and a `smoke` tier (leading prompts as a plumbing canary), a negative-control self-test, and a deterministic wrong-tool-name trap that flags onboarding referencing tools the MCP server doesn't register. Each run emits a self-contained HTML viewer (overview, per-scenario prompts, full message transcript, phantom call-outs) alongside JSON reports with baseline regression diffing. - `@agent-relay/harnesses` adds a `grok` PTY harness for the Grok CLI, including Relaycast MCP support for spawned agents. - `@agent-relay/harnesses` is now published to npm, so SDK consumers can install the prebuilt PTY harnesses and harness-authoring helpers. - `agent-relay drive` and `agent-relay passthrough` add adaptive predictive echo so typing stays responsive when driving a high-latency or remote agent, and stays invisible on fast local links. diff --git a/package.json b/package.json index 89f4e3352..666bdb0d0 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,14 @@ "test:integration:broker": "npx tsc -p tests/integration/broker/tsconfig.json && cd tests/integration/broker && node --test dist/*.test.js", "test:integration:broker:build": "npx tsc -p tests/integration/broker/tsconfig.json", "test:integration:broker:run": "cd tests/integration/broker && node --test dist/*.test.js", + "eval:build": "npx tsc -p tests/integration/broker/evals/tsconfig.json", + "eval:unit": "vitest run tests/integration/broker/evals", + "eval:selftest": "npm run eval:build && node tests/integration/broker/dist/evals/selftest.js", + "eval:toolcheck": "npm run eval:build && node tests/integration/broker/dist/evals/toolcheck-cli.js", + "eval:html": "npm run eval:build && node tests/integration/broker/dist/evals/report/render-cli.js", + "eval": "npm run eval:build && cd tests/integration/broker && RELAY_INTEGRATION_REAL_CLI=1 node dist/evals/runner.js", + "eval:claude": "npm run eval:build && cd tests/integration/broker && RELAY_INTEGRATION_REAL_CLI=1 node dist/evals/runner.js --harness=claude", + "eval:matrix": "npm run eval:build && cd tests/integration/broker && RELAY_INTEGRATION_REAL_CLI=1 node dist/evals/runner.js --harness=claude,codex,gemini,grok", "lint": "npm --prefix packages/cli run lint", "knip": "knip", "syncpack": "syncpack lint", diff --git a/specs/fleet-delivery.md b/specs/fleet-delivery.md new file mode 100644 index 000000000..4bde96f43 --- /dev/null +++ b/specs/fleet-delivery.md @@ -0,0 +1,189 @@ +# Fleet Delivery — Agents, Nodes, and Reliable Messaging + +**Status**: Draft +**Date**: 2026-06-06 +**Author**: Design session (Will + Claude) + +--- + +## 1. Vision + +Run agents across many machines. Each machine (a **node**) can run a specific set of compute — some can spawn Claude agents, some Codex, some both. Relaycast is the control plane: agents are equal peers in a flat messaging fabric, nodes advertise what they can spawn, and Relaycast routes messages to agents wherever they live and places new agents onto nodes that can run them. + +The goal is the simplest deployable unit that maximizes the environments a node can run in, with messaging that survives flaky networks and agent restarts. + +## 2. The frame: two planes + +Everything below lives on one of two planes. Keeping them separate is what keeps the model simple. + +- **Messaging fabric — flat, all equal `agents`.** Every agent is a peer: a stable identity that sends, receives, and may expose actions. No agent is above or routed "through" another. +- **Compute layer — `nodes`.** A node is a machine where some agents run. It has a **broker** runtime and a set of **capabilities** (what it can spawn). Agents sharing a node is a deployment fact, not a relationship in the fabric. + +There is **no "participant" umbrella and no agent subtype.** "Orchestrate vs communicate" is not a type distinction — it's just whether an agent is colocated on a node-with-broker or self-connected (see *location*, §5). + +## 3. Core concepts + +| Concept | What it is | Plane | +|---|---|---| +| **Agent** | a peer in the fabric: identity, send/receive, exposed actions | messaging | +| **Node** | a named machine that runs agents and advertises capabilities | compute | +| **Broker** | a node's runtime/delivery engine — **infra, not a peer** (not in the agent roster) | compute | +| **Location** | where Relaycast routes an agent's **inbound** | routing detail | +| **Capability** | what a node can spawn (e.g. `spawn:codex`) | compute | +| **Action** | something an agent exposes/invokes in the fabric | messaging | + +The broker is node infrastructure with a control connection to Relaycast. A colocated PTY agent receiving via its broker is the same kind of plumbing as a NIC delivering to a process — a location, not a hierarchy. + +## 4. Identities & naming + +- **Agent**: stable `agent_id` + workspace-unique **name** (the addressable handle). One **active location** per agent name (a second live claimant is rejected; migration is explicit). +- **Node**: workspace-unique **name**, operator-set at startup (default: hostname), optionally backed by a stable internal id so a name can move to a replacement machine. Same uniqueness rule as agents: one live owner. +- The node's broker is the **token authority** for agents it spawns: it asks Relaycast to mint the agent identity + token on spawn, hands the token to the agent, and binds its location. + +## 5. Delivery model — keep + delete + +Most of this already exists; the work is mostly removal. + +**Outbound (all agents): direct & stateless.** An agent sends with its own token straight to Relaycast (PTY agents via their MCP send tools; SDK agents via their own send). Sends are request/response — **no persistent connection required for sending**, and the broker is never in the send path. + +**Inbound: delivered to the agent's location.** Location has exactly two shapes — a field, not a type: +- **Self-connected** (event-loop programs — SDK agents): the agent's own WS + message handler. This *is* the delivery path. +- **Via its node** (raw PTY harnesses with no event loop): the node's broker receives and injects into the agent's stdin. + - `steer` = inject + interrupt to a prompt now. + - `wait` = write to the buffer; the harness reads at its next prompt (the PTY defers naturally). + +**Invariant: an agent has exactly one location.** This is the whole cleanup — the old redundancy was a via-node agent *also* holding its own WS. One location → no double delivery, no special-casing. + +**Delete (these only ever applied to PTY agents):** +- The per-agent Relaycast WS (`RealtimeResourceBridge`). +- The MCP **resource layer** (`relay://inbox`, `relay://channels/...`, subscribe/notify) — it assumes a reactive client; turn-based harnesses don't subscribe. +- The **inbox piggyback** (stapling inbox onto every tool result). + +**Keep:** on-demand read/query tools (`check_inbox`, `list_messages`, `thread`, `search`, `list_channels`) as **stateless cloud-direct reads** with the agent token. Pulling on your own initiative doesn't need a persistent connection. The MCP server for PTY agents becomes outbound + reads only. + +**Consistency bolt:** delivery acknowledgement marks a message delivered/read in Relaycast, so a cloud-backed `check_inbox` never re-surfaces something already delivered. One source of truth for *history* (cloud), one for *delivery* (the location); the ack bridges them. + +**Why the deleted layers existed (so we don't rebuild them):** the original design was MCP-idiom-first — inbox/channels as subscribable resources, the textbook way to surface stateful data. Turn-based harnesses didn't react to `resources/updated`, so the piggyback was bolted on, and stdin injection became the reliable push. Nothing was removed, leaving three overlapping inbound paths. **Lesson: design delivery around the agent's execution model (turn-based vs event-loop), not the protocol's idiom.** + +## 6. Spawn & placement + +**Spawn is not a protocol concept — it's a node capability**, expressed through the existing action mechanism (`actions.register('create', handler → driver.spawn)`). The "how spawning happens here" is a node-side harness definition (`definePtyHarness` / `StaticPtyHarnessDefinition`) — the script you provide when spinning up a node. A node advertises the capabilities it defines (e.g. `spawn:claude`, `spawn:codex`). + +**Placement** takes an optional target: + +``` +spawn { capability, node?: | "self", session_ref?, ttl_override? } + +eligible = nodes where + (node.name == target if target given) + ∧ capability ∈ node.capabilities + ∧ node.live ∧ capacity_available +place: target if given, else least-loaded(eligible) +``` + +- `node: "gpu-box-1"` → must place there. Capability mismatch → **hard fail**. Offline → bounded-queue (or fail-fast per override). +- `node: "self"` → same node as the requester (the common **colocation** case: shared working dir, local artifacts). An agent needn't know its node's name. +- `node` omitted → scheduler picks any eligible (least-loaded). +- None eligible → bounded-queue, then fail. + +**Resume is a special case of targeted spawn.** "Resume agent X" = spawn with `node: ` + its `session_ref`. There is no separate resume concept — it is placement constrained to the origin node plus a session reference (see §8.2). + +**Node roster:** because agents/humans/schedulers can target by name, Relaycast exposes a **node discovery query** (name, capabilities, liveness, load) — the compute-layer roster, parallel to the agent roster, and what a UI like Pear renders. + +## 7. Reliable action invocation (spawn rides on this) + +Spawn inherits the action system's async invocation machinery (`invocationId`, ack, result) rather than a bespoke state machine. Exactly-once placement is impossible (dispatch, node dies before ack — did it start?), so the contract is **idempotency + at-least-once + reconcile**: + +- `invocationId` is the idempotency key. A node dedups invocations by it; a requester retrying with the same id never double-spawns. +- Invocation lifecycle: `pending → dispatched(node) → completed(agent_id)`. +- **Dispatch timeout / node lost** before completion → **reschedule** to another eligible node with the same `invocationId`. +- **Reconcile on reconnect:** a node re-announces its live agent inventory (with `agent_id`, name, `invocationId`, `session_ref`) — see §9. If an invocation already completed elsewhere → the duplicate is released. **First to `completed` wins.** A dead broker brings no agents back (its children died with it), so the dead-node case reschedules cleanly; duplicates only arise from a live-broker uplink blip and are reconciled away. + +## 8. Durability — bounded-durable mailbox + +**Decision: bounded-durable.** A message for an unreachable agent is held for a TTL and delivered when it's reachable again; dead-lettered after. Reliable without infinite state. + +### 8.1 Message state machine (held in Relaycast) + +``` +queued ──deliver(seq)──▶ delivered ──ack──▶ acked (≈read) + │ + └── TTL expiry ──▶ dead-letter +``` + +- **At-least-once + dedup by `msg_id`.** Per-agent ordering falls out of a monotonic per-location `seq`. +- Relaycast pushes `queued` messages to the agent's location with a per-session `seq`; the location injects/handles, then **cumulative ack** (`up_to_seq`) advances them to `acked`. + +### 8.2 Identity continuity requires session continuity + +Reclaiming a mailbox without resuming the actual session would dump a backlog on a context-less process — worse than dead-lettering. So an agent reclaims its identity + held mailbox **only by resuming its session** (= origin-targeted spawn + `session_ref`, §6): + +- **`resumable`** is a per-harness capability; on spawn of a resumable harness the broker captures the `session_ref` and reports it to Relaycast. +- **Resume is node-local (v1):** harness session state lives on the origin node's disk, so resumable agents are **node-sticky**. If the origin node is permanently gone, the session is unrecoverable → identity terminal → mailbox dead-lettered. (Cross-node resume needs cloud-synced session state — deferred, §10.) +- No resumable capability / no recoverable session → respawn is a **new identity**; the old mailbox is dead-lettered (senders notified). + +### 8.3 Mailbox resolution + +| Situation | Resolution | +|---|---| +| Location temporarily unreachable (uplink/WS blip; process alive) | Hold + deliver on reconnect. Applies to **all** agents. | +| Process dead, resumable + session recoverable | Hold up to TTL; flush on origin-targeted resume (oldest-first). | +| Process dead, non-resumable OR session lost | **Dead-letter immediately** (notify senders). | + +Consequence: persistence *across process death* is a **resumable-only** property. Non-resumable agents are ephemeral-on-death (still resilient to transient blips while alive). + +### 8.4 Where state lives + +- **Relaycast** holds all durable state (source of truth): mailboxes, agent records (`resumable`, `session_ref`, origin node), locations, node registry. Must survive Relaycast restarts. +- **Broker** keeps only in-memory per-session state: `seq` cursor, dedup set, local pending-injection queue. **No disk needed for delivery durability.** + - Uplink blip, broker alive → cursor/dedup survive → clean replay, no duplicates. + - Broker process dies → its child agents die too → they respawn and *want* redelivery → redelivery is correct, not duplicate. + +### 8.5 One durable store + +The per-agent mailbox **subsumes** any per-node replay buffer: node-disconnect replay is just "redeliver this node's agents' unacked mail on reconnect." The per-location `seq` + ack is the at-least-once transport on top of the mailbox. + +### 8.6 Policy (defaults, all tunable) + +- **TTL:** workspace default (~1h placeholder) + per-message override ("5m or drop" for time-sensitive; longer for durable tasks). +- **Dead-letter on expiry:** retain briefly + **emit `delivery_failed`/expired to the sender** (reuse existing event). Silent drop is the wrong default. +- **Mailbox overflow:** **reject-new** with sender feedback (so senders learn the agent is backed up) rather than drop-oldest. +- **Inbound to a down-but-resumable agent:** **lazy by default** (queue; resume via restart-policy or explicit respawn), **eager opt-in** ("wake on message"). +- **Restart policy:** a resumable agent with auto-restart → broker auto-resumes the session on its node and flushes the mailbox. + +## 9. Node lifecycle & control surface + +A node's broker holds one control connection to Relaycast, serving two roles: **compute provider** (advertises capabilities, receives spawn/release action invocations, reports results) and **delivery relay** (receives inbound for the PTY agents located on it, injects, acks). + +- **Register** (on connect): node name, capabilities, version, `max_agents`, tags, and a resume cursor for replay. +- **Heartbeat** (~10–15s): `load`, `active_agents`. Relaycast TTL marks offline → stop placing there; mark its located agents unreachable. +- **Reconnect inventory sync:** after register, the broker re-announces its full live agent inventory (`agent_id`, name, `invocationId`, `session_ref`). Relaycast reconciles **locations** and open **invocations** (§7) from it. +- **Deregister:** graceful on shutdown; else liveness TTL. + +**Narrow control surface** (the only Relaycast protocol the broker implements — *not* the full `@relaycast/sdk`; channels/threads/reactions/search stay in the agent SDK): + +- **Broker → Relaycast:** `node.register`, `node.heartbeat`, `node.deregister`, `agent.register` (bind location), `agent.deregister`, `delivery.ack`, `action.result`, `inventory.sync` +- **Relaycast → Broker:** `action.invoke` (spawn/release are actions), `deliver`, `ping` + +## 10. Deferred / open + +- **Cross-node session resume** via cloud-synced session state (lifts node-sticky in §8.2). +- **Same-node fast-path (perf):** local A→B delivery bypassing the cloud, and whether it's allowed when the uplink is down. +- **Node tags / fuzzy targeting** (`gpu` instead of an exact name) and **access control** on who can target / spawn on which nodes. +- **Exact tunables:** TTL, mailbox depth cap, heartbeat interval/TTL, dispatch timeout. +- **Mark-read mechanism:** broker auto-mark on delivery (preferred) vs explicit `mark_read` tool — keep explicit only if a product reason emerges. + +## 11. Pre-implementation verification (for the §5 delete) + +1. No agent/harness lacking an injectable stdin that relies on resources for inbound (a purely-programmatic MCP agent would need a self-connected location instead). +2. No external/third-party MCP client consuming `relay://` resources as an API. + +## 12. Decisions log + +- **Frame:** flat fabric of equal **agents** (peers) + a compute layer of named **nodes**; **broker is node infra, not a peer**. No "participant" umbrella, no agent subtype. Orchestrate/communicate is just an agent's **location** (via-node vs self-connected). +- **Delivery:** outbound is direct & stateless; inbound goes to the agent's single **location**. Invariant: one location per agent. Delete the per-agent Relaycast WS, MCP resource layer, and piggyback (PTY-only); keep cloud-direct read tools; delivery-ack marks read. +- **Spawn = node capability** via the action system; **placement** is targeted (`name`/`self`) or any (least-loaded); **resume = origin-targeted spawn + session_ref**. Node roster for discovery. +- **Reliable invocation:** idempotency (`invocationId`) + at-least-once + reschedule + reconcile (first-to-`completed` wins); rides the action invocation machinery, no bespoke spawn state machine. +- **Durability:** bounded-durable mailbox; at-least-once + dedup by `msg_id`; per-location `seq`; cumulative ack. Mailbox subsumes node replay; broker stateless across restarts; Relaycast is source of truth. +- **Identity continuity requires session continuity:** resume is node-local; resumable agents node-sticky; otherwise fresh identity + dead-letter old mailbox. +- **Policy:** dead-letter → notify sender; overflow → reject-new; down-but-resumable → lazy-resume by default (eager opt-in); one active location per agent name. diff --git a/tests/integration/broker/evals/README.md b/tests/integration/broker/evals/README.md new file mode 100644 index 000000000..0cc64011b --- /dev/null +++ b/tests/integration/broker/evals/README.md @@ -0,0 +1,107 @@ +# Agent messaging evals + +Confirms that, after changes to the broker, MCP server, injected protocol skill, +or an agent CLI, agents can still talk to each other and follow the messaging +protocol — and measures **how often agents fail to use the MCP/CLI to send +messages** (a "phantom message": prose like _"I'll tell Lead the result"_ with no +actual send). + +Scoring is **deterministic**, derived entirely from broker events: + +- `relay_inbound` (from an agent) = ground truth that a messaging tool was + actually invoked. +- `worker_stream` = the agent's raw output, where phantom intent is detected. + +## Tiers + +- **`realistic`** (default benchmark) — natural-language prompts where messaging + is incidental to real work. Nothing names a tool; the protocol must come from + the production onboarding (injected skill + broker hints). This is what + measures whether agents actually remember to message under realistic + conditions. Results are probabilistic — use `--repeat=N` to get a stable rate. +- **`smoke`** — leading prompts that name the exact tool ("…call + `mcp__agent-relay__send_dm`"). A plumbing canary that proves the + broker→MCP→agent→scoring path works; near-100% by construction, so it does not + measure protocol retention. + +Select with `--tier=realistic|smoke|all` (default `realistic`). + +## Metrics + +| Metric | Meaning | +| --- | --- | +| `messageSentRate` | actual sends ÷ expected sends | +| `phantomRate` / `phantomCount` | forward-looking intents with no backing send | +| `protocolAdherence` | ACK-before-DONE, correct-channel reply | +| `deliverySuccessRate` | scenarios with no dropped / ACL-denied deliveries | +| `wrongChannelReplies` | replies sent to a DM/other channel instead of the shown one | + +## Running + +```bash +# Scorer unit tests — no CLIs, fast, runs in normal CI via vitest +npm run eval:unit + +# Negative control — proves the eval goes RED on a broken/absent messaging path. +# Deterministic, no real LLM (uses the `cat` shim), costs no tokens. +npm run eval:selftest + +# Wrong-tool-name trap — flags onboarding that tells agents to call tools the +# MCP server doesn't register. Deterministic, no broker, no tokens. +npm run eval:toolcheck + +# Live evals (spawn real agent CLIs) — gated, needs the broker binary + CLIs +npm run eval # realistic tier, default harness matrix +npm run eval:claude # claude only, realistic tier +npm run eval:matrix # claude, codex, gemini, grok + +# Direct runner flags (after `npm run eval:build`) +cd tests/integration/broker +RELAY_INTEGRATION_REAL_CLI=1 node dist/evals/runner.js \ + --harness=claude,codex --tier=realistic --repeat=3 --baseline=path.json +# or pin specific scenarios: +RELAY_INTEGRATION_REAL_CLI=1 node dist/evals/runner.js --scenario=r02-forget-to-report +``` + +Live runs require `RELAY_INTEGRATION_REAL_CLI=1`, the `agent-relay-broker` binary +(`target/debug/`), and the named CLI on `PATH`; missing CLIs are skipped, not +failed. `--scenario` accepts a comma-separated list of scenario ids. + +## Reports & HTML viewer + +Each run writes to `tests/integration/broker/evals-reports/` (gitignored): + +- `report--.json` — full machine-readable result. +- `report--.html` — **self-contained viewer; open in a browser.** + Shows the metric overview, and per scenario the agents + their task prompts and + the full message transcript (who sent what to whom, and whether the agent + responded — agent sends are right-aligned, injected stimulus left-aligned). + Phantom messages are called out in red. +- `matrix-.{json,html}` — roll-up across harnesses (when more than one). + +Pass `--baseline=` to fail the run on regression (phantom rate +up, send rate down). Regenerate HTML from any saved JSON report with: + +```bash +npm run eval:html -- evals-reports/report--.json +``` + +## Layout + +- `scenarios/` — coordination tasks. Smoke: `01`–`04` (leading). Realistic: + `r01-incidental-report` (work + report back), `r02-forget-to-report` (real task + with the coordination ask at the end — the phantom risk), `r03-proactive-handoff` + (decide to message a peer), `r04-channel-vs-dm` (reply where the conversation is). +- `scoring/` — pure functions over `BrokerEvent[]`: `phantom.ts`, `protocol.ts`, + `metrics.ts`, `stream-clean.ts`, `base.ts`, `toolcheck.ts`. Unit-tested via + `*.unit.test.ts`. +- `runner.ts` — harness × scenario matrix, tier selection, report writing, baseline diff. +- `report/` — JSON + HTML writers and `compareReports`. +- `selftest.ts` — negative control. `toolcheck-cli.ts` — wrong-tool-name trap. + +## Adding a scenario + +Add `scenarios/.ts` exporting an `EvalScenario` (set `tier`), register it in +`scenarios/index.ts`, and build its `ScenarioResult` with the `scoring/` helpers +(`baseScore` for sends/phantoms/delivery/transcript, plus the relevant +`protocol.ts` check). Realistic scenarios must not name a tool in the prompt. diff --git a/tests/integration/broker/evals/eval.test.ts b/tests/integration/broker/evals/eval.test.ts new file mode 100644 index 000000000..539b3ad6e --- /dev/null +++ b/tests/integration/broker/evals/eval.test.ts @@ -0,0 +1,45 @@ +/** + * node:test wrapper so the eval scenarios can also be run and observed through + * the standard broker integration test runner. Gated behind + * RELAY_INTEGRATION_REAL_CLI; uses the first available CLI. + * + * Run: + * npx tsc -p tests/integration/broker/tsconfig.json + * RELAY_INTEGRATION_REAL_CLI=1 node --test dist/evals/eval.test.js + * + * The standalone runner (dist/evals/runner.js) is the primary entrypoint and is + * what produces JSON reports + the harness matrix. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { BrokerHarness, checkPrerequisites, uniqueSuffix } from '../utils/broker-harness.js'; +import { skipUnlessAnyCli, sleep } from '../utils/cli-helpers.js'; +import { SCENARIOS } from './scenarios/index.js'; + +for (const scenario of SCENARIOS) { + test(`eval: ${scenario.id} — ${scenario.title}`, { timeout: scenario.timeoutMs }, async (t) => { + const reason = checkPrerequisites(); + if (reason) return t.skip(reason); + const cli = skipUnlessAnyCli(t); + if (!cli) return; + + const harness = new BrokerHarness({ channels: scenario.channels }); + await harness.start(); + try { + const result = await scenario.run({ harness, cli, suffix: uniqueSuffix(), sleep }); + console.log( + ` ${result.id}: sent=${result.sent}/${result.expected} ` + + `phantoms=${result.phantoms.length} ` + + `adherence=${result.protocolAdherence ?? 'n/a'} ` + + `wrongChan=${result.wrongChannelReplies} notes=${result.notes ?? ''}` + ); + for (const p of result.phantoms) { + console.log(` phantom: [${p.agent}] ${p.verb} ${p.target ?? ''} — "${p.snippet}"`); + } + assert.ok(result.pass, `Scenario ${result.id} failed: ${JSON.stringify(result, null, 2)}`); + } finally { + await harness.stop().catch(() => {}); + } + }); +} diff --git a/tests/integration/broker/evals/report/html.ts b/tests/integration/broker/evals/report/html.ts new file mode 100644 index 000000000..a2bcf8a95 --- /dev/null +++ b/tests/integration/broker/evals/report/html.ts @@ -0,0 +1,178 @@ +/** + * Render an eval report as a self-contained HTML page — open it directly in a + * browser, no server needed. Shows an overview (harness, metrics), and per + * scenario the agents + their prompts and the full message transcript (what was + * sent, by whom, and whether the agent responded). + */ +import type { EvalReport, MatrixReport, MetricSet, ScenarioResult, TranscriptEntry } from '../types.js'; + +function esc(s: string): string { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function pct(x: number): string { + return `${Math.round(x * 100)}%`; +} + +function shortSha(sha: string): string { + return sha === 'unknown' ? sha : sha.slice(0, 8); +} + +const STYLE = ` +:root { + --bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --line: #2a3340; + --txt: #e6edf3; --dim: #8b97a6; --accent: #5ab0ff; --green: #3fb950; + --red: #f85149; --amber: #d29922; --mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--txt); + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +.wrap { max-width: 960px; margin: 0 auto; padding: 32px 20px 80px; } +h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: -0.01em; } +.sub { color: var(--dim); font: 12px/1.6 var(--mono); margin-bottom: 24px; } +.sub .badge { color: var(--txt); background: var(--panel2); border: 1px solid var(--line); + padding: 1px 7px; border-radius: 5px; margin-right: 6px; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 28px; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; } +.card .v { font-size: 24px; font-weight: 650; font-family: var(--mono); letter-spacing: -0.02em; } +.card .l { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; } +.card.good .v { color: var(--green); } .card.bad .v { color: var(--red); } .card.warn .v { color: var(--amber); } +.scn { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; margin-bottom: 16px; overflow: hidden; } +.scn > header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); } +.scn h2 { font-size: 15px; margin: 0; flex: 1; } +.scn .id { color: var(--dim); font: 11px var(--mono); } +.pill { font: 11px var(--mono); font-weight: 700; padding: 2px 9px; border-radius: 20px; letter-spacing: 0.03em; } +.pill.pass { color: #061a0b; background: var(--green); } .pill.fail { color: #2a0606; background: var(--red); } +.stats { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 10px 16px; color: var(--dim); + font: 12px var(--mono); border-bottom: 1px solid var(--line); } +.stats b { color: var(--txt); font-weight: 600; } .stats .x { color: var(--red); } +.body { padding: 12px 16px; } +details { margin-bottom: 12px; } summary { cursor: pointer; color: var(--accent); font-size: 13px; user-select: none; } +.agent { margin: 8px 0 0; } +.agent .nm { font: 12px var(--mono); color: var(--txt); } .agent .role { color: var(--dim); } +pre { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; + white-space: pre-wrap; word-break: break-word; font: 12px/1.5 var(--mono); color: var(--dim); margin: 4px 0 0; } +.chat { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; } +.msg { max-width: 82%; padding: 8px 12px; border-radius: 12px; } +.msg .meta { font: 10px var(--mono); color: var(--dim); margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.04em; } +.msg .txt { white-space: pre-wrap; word-break: break-word; font-size: 13px; } +.msg.in { align-self: flex-start; background: var(--panel2); border: 1px solid var(--line); border-bottom-left-radius: 3px; } +.msg.out { align-self: flex-end; background: #15324d; border: 1px solid #234a6b; border-bottom-right-radius: 3px; } +.arrow { color: var(--accent); } +.empty { color: var(--dim); font: 12px var(--mono); padding: 6px 0; } +.phantoms { margin-top: 12px; border: 1px solid #5a2526; background: #2a1415; border-radius: 8px; padding: 10px 12px; } +.phantoms .h { color: var(--red); font: 12px var(--mono); font-weight: 700; margin-bottom: 6px; } +.phantoms li { font: 12px var(--mono); color: var(--txt); margin: 3px 0; } +.phantoms .snip { color: var(--dim); } +a { color: var(--accent); } +table { width: 100%; border-collapse: collapse; font: 13px var(--mono); } +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); } +th { color: var(--dim); font-weight: 600; font-size: 11px; text-transform: uppercase; } +`; + +function metricCards(m: MetricSet): string { + const cards: Array<{ v: string; l: string; cls?: string }> = [ + { v: pct(m.messageSentRate), l: 'Message-sent rate', cls: m.messageSentRate >= 1 ? 'good' : 'warn' }, + { v: `${pct(m.phantomRate)} (${m.phantomCount})`, l: 'Phantom rate', cls: m.phantomCount > 0 ? 'bad' : 'good' }, + { v: pct(m.protocolAdherence), l: 'Protocol adherence', cls: m.protocolAdherence >= 1 ? 'good' : 'warn' }, + { v: pct(m.deliverySuccessRate), l: 'Delivery success', cls: m.deliverySuccessRate >= 1 ? 'good' : 'bad' }, + { v: String(m.wrongChannelReplies), l: 'Wrong-channel', cls: m.wrongChannelReplies > 0 ? 'bad' : 'good' }, + { v: `${m.scenariosPassed}/${m.scenariosTotal}`, l: 'Scenarios passed', cls: m.scenariosPassed === m.scenariosTotal ? 'good' : 'bad' }, + ]; + return `
${cards + .map((c) => `
${esc(c.v)}
${esc(c.l)}
`) + .join('')}
`; +} + +function transcriptHtml(entries: TranscriptEntry[]): string { + if (entries.length === 0) return `
No messages captured.
`; + return `
${entries + .map((e) => { + const side = e.fromAgent ? 'out' : 'in'; + const meta = `${esc(e.from)} ${esc(e.target)}${e.threadId ? ` · thread ${esc(e.threadId)}` : ''}`; + return `
${meta}
${esc(e.body) || '(empty)'}
`; + }) + .join('')}
`; +} + +function scenarioHtml(s: ScenarioResult): string { + const stats = [ + `sent ${s.sent}/${s.expected}`, + `phantoms ${s.phantoms.length}`, + s.protocolAdherence !== null ? `protocol ${pct(s.protocolAdherence)}` : '', + `wrong-channel ${s.wrongChannelReplies}`, + `delivery ${s.deliveryOk ? 'ok' : 'FAILED'}`, + s.notes ? `· ${esc(s.notes)}` : '', + ].filter(Boolean); + + const agents = s.agents.length + ? `
Agents & prompts (${s.agents.length})${s.agents + .map( + (a) => + `
${esc(a.name)} · ${esc(a.cli)}${a.role ? ` · ${esc(a.role)}` : ''}
${esc(a.prompt)}
` + ) + .join('')}
` + : ''; + + const phantoms = s.phantoms.length + ? `
⚠ ${s.phantoms.length} phantom message(s) — intent stated, no tool call
    ${s.phantoms + .map( + (p) => + `
  • [${esc(p.agent)}] "${esc(p.verb)}${p.target ? ` ${esc(p.target)}` : ''}" — ${esc(p.snippet)}
  • ` + ) + .join('')}
` + : ''; + + return `
+
+ ${s.pass ? 'PASS' : 'FAIL'} +

${esc(s.title)}

+ ${esc(s.id)} +
+
${stats.join('· ')}
+
${agents}${transcriptHtml(s.transcript)}${phantoms}
+
`; +} + +/** Render one harness report as a full standalone HTML document. */ +export function renderReportHtml(report: EvalReport): string { + const sub = [ + `${esc(report.harness)}`, + `git ${esc(shortSha(report.gitSha))}`, + esc(report.startedAt), + `${(report.durationMs / 1000).toFixed(1)}s`, + report.env.repeat > 1 ? `repeat ${report.env.repeat}` : '', + ].filter(Boolean); + return ` + +Agent Messaging Evals — ${esc(report.harness)} +
+

Agent Messaging Evals

+
${sub.join(' · ')}
+ ${metricCards(report.metrics)} + ${report.scenarios.map(scenarioHtml).join('')} +
`; +} + +/** Render the matrix roll-up: one row per harness, linking to its report. */ +export function renderMatrixHtml(matrix: MatrixReport, links: Record): string { + const rows = Object.entries(matrix.harnesses) + .map(([h, m]) => { + const link = links[h] ? `${esc(h)}` : esc(h); + return `${link}${pct(m.messageSentRate)}${pct(m.phantomRate)} (${m.phantomCount})${pct(m.protocolAdherence)}${pct(m.deliverySuccessRate)}${m.wrongChannelReplies}${m.scenariosPassed}/${m.scenariosTotal}`; + }) + .join(''); + return ` + +Agent Messaging Evals — matrix +
+

Agent Messaging Evals — harness matrix

+
git ${esc(shortSha(matrix.gitSha))} · ${esc(matrix.startedAt)}
+ + ${rows}
HarnessSentPhantomProtocolDeliveryWrong-chanScenarios
+
`; +} diff --git a/tests/integration/broker/evals/report/render-cli.ts b/tests/integration/broker/evals/report/render-cli.ts new file mode 100644 index 000000000..f0dacb354 --- /dev/null +++ b/tests/integration/broker/evals/report/render-cli.ts @@ -0,0 +1,25 @@ +/** + * Regenerate an HTML report from an existing JSON report file. + * + * node dist/evals/report/render-cli.js + * + * Writes a sibling .html next to the JSON and prints its path. + */ +import fs from 'node:fs'; + +import { renderReportHtml } from './html.js'; +import { readReport } from './write.js'; + +function main(): void { + const input = process.argv[2]; + if (!input) { + console.error('Usage: render-cli '); + process.exit(2); + } + const report = readReport(input); + const out = input.replace(/\.json$/, '') + '.html'; + fs.writeFileSync(out, renderReportHtml(report)); + console.log(`html → ${out}`); +} + +main(); diff --git a/tests/integration/broker/evals/report/write.ts b/tests/integration/broker/evals/report/write.ts new file mode 100644 index 000000000..f478e1c11 --- /dev/null +++ b/tests/integration/broker/evals/report/write.ts @@ -0,0 +1,124 @@ +/** + * JSON report writing and cross-run comparison for the eval suite. + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { EvalReport, MatrixReport, MetricSet } from '../types.js'; +import { renderMatrixHtml, renderReportHtml } from './html.js'; + +/** Directory where reports are written (gitignored). */ +export function reportsDir(): string { + // Compiled location: dist/evals/report/ → scenario source lives under evals/. + return path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..', 'evals-reports'); +} + +/** Resolve the current git SHA, or "unknown" if unavailable. */ +export function gitSha(): string { + try { + return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); + } catch { + return 'unknown'; + } +} + +/** Filesystem-safe ISO timestamp (colons replaced). */ +export function isoStamp(now: Date): string { + return now.toISOString().replace(/[:.]/g, '-'); +} + +function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +/** Write a per-harness report. Returns the file path. */ +export function writeReport(report: EvalReport, stamp: string): string { + const dir = reportsDir(); + ensureDir(dir); + const file = path.join(dir, `report-${stamp}-${report.harness}.json`); + fs.writeFileSync(file, JSON.stringify(report, null, 2)); + return file; +} + +/** Write a per-harness HTML report. Returns the file path. */ +export function writeReportHtml(report: EvalReport, stamp: string): string { + const dir = reportsDir(); + ensureDir(dir); + const file = path.join(dir, `report-${stamp}-${report.harness}.html`); + fs.writeFileSync(file, renderReportHtml(report)); + return file; +} + +/** Write the matrix roll-up across harnesses. Returns the file path. */ +export function writeMatrix(matrix: MatrixReport, stamp: string): string { + const dir = reportsDir(); + ensureDir(dir); + const file = path.join(dir, `matrix-${stamp}.json`); + fs.writeFileSync(file, JSON.stringify(matrix, null, 2)); + return file; +} + +/** Write the matrix HTML index linking to each harness's HTML report. */ +export function writeMatrixHtml(matrix: MatrixReport, stamp: string): string { + const dir = reportsDir(); + ensureDir(dir); + const links: Record = {}; + for (const harness of Object.keys(matrix.harnesses)) { + links[harness] = `report-${stamp}-${harness}.html`; + } + const file = path.join(dir, `matrix-${stamp}.html`); + fs.writeFileSync(file, renderMatrixHtml(matrix, links)); + return file; +} + +/** Read a previously-written report from disk. */ +export function readReport(file: string): EvalReport { + return JSON.parse(fs.readFileSync(file, 'utf8')) as EvalReport; +} + +export interface MetricDelta { + metric: keyof MetricSet; + baseline: number; + current: number; + delta: number; + /** True if the delta is a regression (worse). */ + regression: boolean; +} + +/** Metrics where a higher value is better; the rest are better when lower. */ +const HIGHER_IS_BETTER: Array = [ + 'messageSentRate', + 'protocolAdherence', + 'deliverySuccessRate', + 'scenariosPassed', +]; + +/** + * Compare two reports' metrics. A regression is flagged when a + * higher-is-better metric drops or a lower-is-better metric rises beyond + * `threshold`. + */ +export function compareReports( + baseline: EvalReport, + current: EvalReport, + threshold = 0.0001 +): MetricDelta[] { + const keys: Array = [ + 'messageSentRate', + 'phantomRate', + 'phantomCount', + 'protocolAdherence', + 'deliverySuccessRate', + 'wrongChannelReplies', + 'scenariosPassed', + ]; + return keys.map((metric) => { + const b = baseline.metrics[metric]; + const c = current.metrics[metric]; + const delta = c - b; + const higherBetter = HIGHER_IS_BETTER.includes(metric); + const regression = higherBetter ? delta < -threshold : delta > threshold; + return { metric, baseline: b, current: c, delta, regression }; + }); +} diff --git a/tests/integration/broker/evals/runner.ts b/tests/integration/broker/evals/runner.ts new file mode 100644 index 000000000..74bb0565e --- /dev/null +++ b/tests/integration/broker/evals/runner.ts @@ -0,0 +1,242 @@ +/** + * Eval runner — executes the scenario × harness matrix against real agent CLIs + * and writes JSON reports. + * + * Usage (compiled): + * RELAY_INTEGRATION_REAL_CLI=1 node dist/evals/runner.js [flags] + * + * Flags: + * --harness=claude,codex Harnesses to run (default: claude,codex,gemini,grok) + * --scenario=01-dm-roundtrip Run a single scenario by id + * --repeat=N Repeat each scenario N times and merge (default: 1) + * --baseline=path.json Compare against a prior report and fail on regression + */ +import { isCliAvailable } from '../utils/cli-helpers.js'; +import { BrokerHarness, checkPrerequisites, uniqueSuffix } from '../utils/broker-harness.js'; +import { sleep } from '../utils/cli-helpers.js'; +import { SCENARIOS, scenarioById, scenariosByTier } from './scenarios/index.js'; +import { aggregateMetrics } from './scoring/metrics.js'; +import { + compareReports, + gitSha, + isoStamp, + readReport, + writeMatrix, + writeMatrixHtml, + writeReport, + writeReportHtml, +} from './report/write.js'; +import { SCHEMA_VERSION } from './types.js'; +import type { EvalReport, EvalScenario, EvalTier, MatrixReport, MetricSet, ScenarioResult } from './types.js'; + +const DEFAULT_HARNESSES = ['claude', 'codex', 'gemini', 'grok']; + +interface Flags { + harnesses: string[]; + scenarioIds?: string[]; + /** 'smoke' | 'realistic' | 'all'. Default 'realistic' (the benchmark). */ + tier: EvalTier | 'all'; + repeat: number; + baseline?: string; +} + +function parseFlags(argv: string[]): Flags { + const flags: Flags = { harnesses: DEFAULT_HARNESSES, tier: 'realistic', repeat: 1 }; + for (const arg of argv) { + const [key, value] = arg.replace(/^--/, '').split('='); + if (key === 'harness' && value) flags.harnesses = value.split(',').map((s) => s.trim()); + else if (key === 'scenario' && value) flags.scenarioIds = value.split(',').map((s) => s.trim()); + else if (key === 'tier' && (value === 'smoke' || value === 'realistic' || value === 'all')) flags.tier = value; + else if (key === 'repeat' && value) flags.repeat = Math.max(1, Number(value) || 1); + else if (key === 'baseline' && value) flags.baseline = value; + } + return flags; +} + +/** Select scenarios: explicit ids win, else filter by tier. */ +function selectScenarios(flags: Flags): EvalScenario[] { + if (flags.scenarioIds) { + return flags.scenarioIds.map(scenarioById).filter((s): s is EvalScenario => Boolean(s)); + } + return flags.tier === 'all' ? SCENARIOS : scenariosByTier(flags.tier); +} + +/** Merge repeated runs of one scenario into a single result. */ +function mergeRepeats(results: ScenarioResult[]): ScenarioResult { + const n = results.length; + const passes = results.filter((r) => r.pass).length; + const adherence = results.filter((r) => r.protocolAdherence !== null); + const first = results[0]; + return { + id: first.id, + title: first.title, + pass: passes / n >= 0.5, + agents: first.agents, + transcript: first.transcript, + sent: Math.round(results.reduce((s, r) => s + r.sent, 0) / n), + expected: first.expected, + phantoms: results.flatMap((r) => r.phantoms), + totalIntents: results.reduce((s, r) => s + r.totalIntents, 0), + protocolAdherence: + adherence.length > 0 + ? adherence.reduce((s, r) => s + (r.protocolAdherence ?? 0), 0) / adherence.length + : null, + wrongChannelReplies: results.reduce((s, r) => s + r.wrongChannelReplies, 0), + deliveryOk: results.every((r) => r.deliveryOk), + events: { + relayInbound: results.reduce((s, r) => s + r.events.relayInbound, 0), + dropped: results.reduce((s, r) => s + r.events.dropped, 0), + aclDenied: results.reduce((s, r) => s + r.events.aclDenied, 0), + }, + notes: `${passes}/${n} runs passed` + (first.notes ? ` · ${first.notes}` : ''), + }; +} + +/** Run one scenario once against a fresh broker. */ +async function runOnce(scenario: EvalScenario, cli: string): Promise { + const harness = new BrokerHarness({ channels: scenario.channels }); + await harness.start(); + try { + return await scenario.run({ harness, cli, suffix: uniqueSuffix(), sleep }); + } finally { + await harness.stop().catch(() => {}); + } +} + +async function runHarness( + cli: string, + scenarios: EvalScenario[], + repeat: number, + startedAt: Date +): Promise { + const results: ScenarioResult[] = []; + for (const scenario of scenarios) { + if (scenario.harnessFilter && !scenario.harnessFilter.includes(cli)) continue; + const runs: ScenarioResult[] = []; + for (let i = 0; i < repeat; i++) { + process.stdout.write(` [${cli}] ${scenario.id} (run ${i + 1}/${repeat})… `); + try { + const result = await runOnce(scenario, cli); + runs.push(result); + console.log(result.pass ? 'PASS' : 'FAIL'); + } catch (err) { + console.log(`ERROR: ${(err as Error)?.message ?? err}`); + runs.push(failedResult(scenario, `error: ${(err as Error)?.message ?? err}`)); + } + } + results.push(repeat > 1 ? mergeRepeats(runs) : runs[0]); + } + return { + schemaVersion: SCHEMA_VERSION, + startedAt: startedAt.toISOString(), + durationMs: Date.now() - startedAt.getTime(), + harness: cli, + gitSha: gitSha(), + env: { realCli: process.env.RELAY_INTEGRATION_REAL_CLI === '1', repeat }, + metrics: aggregateMetrics(results), + scenarios: results, + }; +} + +function failedResult(scenario: EvalScenario, notes: string): ScenarioResult { + return { + id: scenario.id, + title: scenario.title, + pass: false, + agents: [], + transcript: [], + sent: 0, + expected: 0, + phantoms: [], + totalIntents: 0, + protocolAdherence: null, + wrongChannelReplies: 0, + deliveryOk: false, + events: { relayInbound: 0, dropped: 0, aclDenied: 0 }, + notes, + }; +} + +function printMetrics(harness: string, m: MetricSet): void { + console.log( + `\n${harness}: sent=${(m.messageSentRate * 100).toFixed(0)}% ` + + `phantom=${(m.phantomRate * 100).toFixed(0)}% (${m.phantomCount}) ` + + `protocol=${(m.protocolAdherence * 100).toFixed(0)}% ` + + `delivery=${(m.deliverySuccessRate * 100).toFixed(0)}% ` + + `wrongChan=${m.wrongChannelReplies} ` + + `scenarios=${m.scenariosPassed}/${m.scenariosTotal}` + ); +} + +async function main(): Promise { + const flags = parseFlags(process.argv.slice(2)); + + if (process.env.RELAY_INTEGRATION_REAL_CLI !== '1') { + console.error('Refusing to run: set RELAY_INTEGRATION_REAL_CLI=1 to run real-CLI evals.'); + process.exit(2); + } + const prereq = checkPrerequisites(); + if (prereq) { + console.error(`Prerequisite missing: ${prereq}`); + process.exit(2); + } + + const scenarios = selectScenarios(flags); + if (scenarios.length === 0) { + console.error(`No scenario matched (scenario=${flags.scenarioIds?.join(',') ?? '-'}, tier=${flags.tier}).`); + process.exit(2); + } + console.log(`Running ${scenarios.length} scenario(s) [tier=${flags.scenarioIds ? 'explicit' : flags.tier}]`); + + const startedAt = new Date(); + const stamp = isoStamp(startedAt); + const matrix: MatrixReport = { + schemaVersion: SCHEMA_VERSION, + startedAt: startedAt.toISOString(), + gitSha: gitSha(), + harnesses: {}, + }; + + let anyRegression = false; + for (const cli of flags.harnesses) { + if (!isCliAvailable(cli)) { + console.log(`\n[${cli}] skipped — CLI not found on PATH`); + continue; + } + console.log(`\n=== Harness: ${cli} ===`); + const report = await runHarness(cli, scenarios, flags.repeat, new Date()); + const file = writeReport(report, stamp); + const htmlFile = writeReportHtml(report, stamp); + matrix.harnesses[cli] = report.metrics; + printMetrics(cli, report.metrics); + console.log(` report → ${file}`); + console.log(` html → ${htmlFile}`); + + if (flags.baseline) { + try { + const deltas = compareReports(readReport(flags.baseline), report); + const regressions = deltas.filter((d) => d.regression); + for (const d of regressions) { + console.log(` ⚠ regression: ${d.metric} ${d.baseline} → ${d.current} (${d.delta > 0 ? '+' : ''}${d.delta.toFixed(3)})`); + } + if (regressions.length > 0) anyRegression = true; + } catch (err) { + console.error(` baseline compare failed: ${(err as Error)?.message ?? err}`); + } + } + } + + if (Object.keys(matrix.harnesses).length > 1 || flags.harnesses.length > 1) { + const matrixFile = writeMatrix(matrix, stamp); + const matrixHtml = writeMatrixHtml(matrix, stamp); + console.log(`\nmatrix → ${matrixFile}`); + console.log(`matrix html → ${matrixHtml}`); + } + + process.exit(anyRegression ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/integration/broker/evals/scenarios/01-dm-roundtrip.ts b/tests/integration/broker/evals/scenarios/01-dm-roundtrip.ts new file mode 100644 index 000000000..a66cac700 --- /dev/null +++ b/tests/integration/broker/evals/scenarios/01-dm-roundtrip.ts @@ -0,0 +1,65 @@ +/** + * Scenario 01 — DM round-trip. + * + * The agent under test (Bob) receives a DM and must reply to the sender using + * the messaging tool. The counterpart (Alice) is simulated via the harness + * driver, so only one real CLI is spawned. PASS = Bob actually sent a reply + * (relay_inbound) with no phantom and clean delivery. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { RESPONSE_MS, STARTUP_MS, waitForSends } from './helpers.js'; + +const TASK = + 'You are Bob, an agent in a shared workspace. When you receive a Relay message, ' + + 'reply to the sender using the mcp__agent-relay__send_dm tool (or the agent-relay ' + + 'message dm send CLI). Keep the reply to one short sentence. Important: actually ' + + 'call the tool to send your reply — do not just describe what you would say.'; + +export const scenario: EvalScenario = { + id: '01-dm-roundtrip', + title: 'DM round-trip (A → B)', + tier: 'smoke', + channels: ['general'], + timeoutMs: 120_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const bob = `bob-${suffix}`; + const alice = 'Alice'; + + await harness.spawnAgent(bob, cli, ['general'], { task: TASK }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: bob, + from: alice, + text: 'Ping — please reply to me with the word PONG.', + }); + + await waitForSends(harness, bob, 1, RESPONSE_MS); + + const events = harness.getEvents(); + const base = baseScore(events, [bob]); + await harness.releaseAgent(bob).catch(() => {}); + + const expected = 1; + const pass = base.sent >= 1 && base.phantoms.length === 0 && base.deliveryOk; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [{ name: bob, cli, role: 'responder', prompt: TASK }], + transcript: base.transcript, + sent: base.sent, + expected, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: null, + wrongChannelReplies: 0, + deliveryOk: base.deliveryOk, + events: base.events, + }; + }, +}; diff --git a/tests/integration/broker/evals/scenarios/02-channel-reply.ts b/tests/integration/broker/evals/scenarios/02-channel-reply.ts new file mode 100644 index 000000000..42ef9260a --- /dev/null +++ b/tests/integration/broker/evals/scenarios/02-channel-reply.ts @@ -0,0 +1,69 @@ +/** + * Scenario 02 — Channel post + correct-channel reply. + * + * The agent must reply IN the channel the message arrived on, using + * post_message, rather than DMing the sender. PASS = a send targeting the + * expected channel, no wrong-channel replies, no phantom. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { scoreChannelReply } from '../scoring/protocol.js'; +import { RESPONSE_MS, STARTUP_MS, waitForSends } from './helpers.js'; + +export const scenario: EvalScenario = { + id: '02-channel-reply', + title: 'Channel post + correct-channel reply', + tier: 'smoke', + channels: ['general'], + timeoutMs: 120_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const worker = `worker-${suffix}`; + const channel = `proj-${suffix}`; + + const task = + `You are an agent in channel #${channel}. When a channel message arrives, reply ` + + `IN THAT CHANNEL using the mcp__agent-relay__post_message tool with channel "${channel}". ` + + `Reply to the channel shown in the message — do not DM the sender. Keep it short, and ` + + `actually call the tool to post your reply.`; + + await harness.spawnAgent(worker, cli, [channel], { task }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: worker, + from: 'Lead', + text: `Relay message from Lead [chk-${suffix}] [#${channel}]: Status check — post READY to this channel.`, + }); + + await waitForSends(harness, worker, 1, RESPONSE_MS); + + const events = harness.getEvents(); + const base = baseScore(events, [worker]); + const channelScore = scoreChannelReply(events, worker, channel); + await harness.releaseAgent(worker).catch(() => {}); + + const pass = + channelScore.repliedToShownChannel && + channelScore.wrongChannelReplies === 0 && + base.phantoms.length === 0 && + base.deliveryOk; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [{ name: worker, cli, role: `member of #${channel}`, prompt: task }], + transcript: base.transcript, + sent: base.sent, + expected: 1, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: channelScore.repliedToShownChannel ? 1 : 0, + wrongChannelReplies: channelScore.wrongChannelReplies, + deliveryOk: base.deliveryOk, + events: base.events, + }; + }, +}; diff --git a/tests/integration/broker/evals/scenarios/03-ack-done.ts b/tests/integration/broker/evals/scenarios/03-ack-done.ts new file mode 100644 index 000000000..39344730c --- /dev/null +++ b/tests/integration/broker/evals/scenarios/03-ack-done.ts @@ -0,0 +1,67 @@ +/** + * Scenario 03 — Lead → worker task with ACK/DONE protocol. + * + * The worker must DM the Lead "ACK: …" on receipt, do a trivial task, then DM + * "DONE: …". The Lead is simulated via the driver. PASS = both messages sent to + * the Lead, in order, with no phantom. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { scoreAckDone } from '../scoring/protocol.js'; +import { RESPONSE_MS, STARTUP_MS, waitForSends } from './helpers.js'; + +export const scenario: EvalScenario = { + id: '03-ack-done', + title: 'Lead → worker task, ACK/DONE protocol', + tier: 'smoke', + channels: ['general'], + timeoutMs: 150_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const worker = `worker-${suffix}`; + const lead = 'Lead'; + + const task = + 'You are a worker agent. Protocol: the moment you receive a task, DM the Lead ' + + '"ACK: " using mcp__agent-relay__send_dm. Then do the task. ' + + 'When finished, DM the Lead "DONE: ". Send ' + + 'status to the Lead via the tool — do not post to a channel and do not just write ' + + 'the text without sending it.'; + + await harness.spawnAgent(worker, cli, ['general'], { task }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: worker, + from: lead, + text: 'Task: compute 2 + 2 and report the result.', + }); + + await waitForSends(harness, worker, 2, RESPONSE_MS); + + const events = harness.getEvents(); + const base = baseScore(events, [worker]); + const ackDone = scoreAckDone(events, worker); + await harness.releaseAgent(worker).catch(() => {}); + + const pass = ackDone.orderOk && base.phantoms.length === 0 && base.deliveryOk; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [{ name: worker, cli, role: 'worker', prompt: task }], + transcript: base.transcript, + sent: base.sent, + expected: 2, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: ackDone.score, + wrongChannelReplies: 0, + deliveryOk: base.deliveryOk, + events: base.events, + notes: `ack=${ackDone.ackPresent} done=${ackDone.donePresent} order=${ackDone.orderOk}`, + }; + }, +}; diff --git a/tests/integration/broker/evals/scenarios/04-relay-chain.ts b/tests/integration/broker/evals/scenarios/04-relay-chain.ts new file mode 100644 index 000000000..290ff7e0a --- /dev/null +++ b/tests/integration/broker/evals/scenarios/04-relay-chain.ts @@ -0,0 +1,90 @@ +/** + * Scenario 04 — 3-agent fact relay. + * + * A secret code is seeded to A, who must DM it to B, who DMs it to C, who posts + * "FINAL: " to #general. The only scenario spawning multiple real CLIs. + * PASS = the full chain completes and the code survives intact to the final + * channel post. Partial chains score fractionally for trend signal. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { scoreRelayChain } from '../scoring/protocol.js'; +import { RESPONSE_MS, STARTUP_MS } from './helpers.js'; + +export const scenario: EvalScenario = { + id: '04-relay-chain', + title: '3-agent fact relay', + tier: 'smoke', + channels: ['general'], + timeoutMs: 240_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const a = `relay-a-${suffix}`; + const b = `relay-b-${suffix}`; + const c = `relay-c-${suffix}`; + const code = `GH-${suffix.slice(-4).toUpperCase()}`; + + const promptA = `You are ${a}. When you receive a secret code, DM it verbatim to ${b} using mcp__agent-relay__send_dm. Actually call the tool.`; + const promptB = `You are ${b}. When you receive a code from ${a}, DM it verbatim to ${c} using mcp__agent-relay__send_dm. Actually call the tool.`; + const promptC = `You are ${c}. When you receive a code, post "FINAL: " to #general using mcp__agent-relay__post_message. Actually call the tool.`; + + await harness.spawnAgent(a, cli, ['general'], { task: promptA }); + await harness.spawnAgent(b, cli, ['general'], { task: promptB }); + await harness.spawnAgent(c, cli, ['general'], { task: promptC }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: a, + from: 'Orchestrator', + text: `Secret code is ${code}. Relay it to ${b}.`, + }); + + // Wait for the final hop (C posting to #general), then settle. + const finalWaiter = harness.waitForEvent( + 'relay_inbound', + RESPONSE_MS * 2, + (e) => e.kind === 'relay_inbound' && e.from === c + ); + await finalWaiter.promise.catch(() => {}); + await sleep(3_000); + + const events = harness.getEvents(); + const agents = [a, b, c]; + const base = baseScore(events, agents); + const chain = scoreRelayChain( + events, + [ + { from: a, to: b }, + { from: b, to: c }, + { from: c, to: '#general' }, + ], + code, + '#general' + ); + for (const name of agents) await harness.releaseAgent(name).catch(() => {}); + + const pass = chain.hopsCompleted === 3 && chain.payloadIntact && base.phantoms.length === 0; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [ + { name: a, cli, role: 'hop 1', prompt: promptA }, + { name: b, cli, role: 'hop 2', prompt: promptB }, + { name: c, cli, role: 'hop 3 (final post)', prompt: promptC }, + ], + transcript: base.transcript, + sent: base.sent, + expected: 3, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: chain.hopsCompleted / 3, + wrongChannelReplies: 0, + deliveryOk: base.deliveryOk, + events: base.events, + notes: `hops=${chain.hopsCompleted}/3 payloadIntact=${chain.payloadIntact} code=${code}`, + }; + }, +}; diff --git a/tests/integration/broker/evals/scenarios/helpers.ts b/tests/integration/broker/evals/scenarios/helpers.ts new file mode 100644 index 000000000..955769abf --- /dev/null +++ b/tests/integration/broker/evals/scenarios/helpers.ts @@ -0,0 +1,40 @@ +/** + * Shared orchestration helpers for eval scenarios. + */ +import type { BrokerEvent } from '@agent-relay/harness-driver'; + +import type { BrokerHarness } from '../../utils/broker-harness.js'; + +/** Time to let a freshly-spawned CLI boot and connect its MCP server. */ +export const STARTUP_MS = 15_000; +/** How long to wait for an agent to respond to a stimulus before scoring. */ +export const RESPONSE_MS = 60_000; + +/** + * Wait until `agent` has emitted at least `count` `relay_inbound` events, or the + * timeout elapses. Resolves either way (scoring inspects the captured events). + * + * Polls against a single hard deadline. (A per-event waiter loop would busy-spin + * here: the harness resolves an already-buffered match instantly, so when an + * agent sends fewer than `count` messages the loop never advances and the + * per-call timeout never fires.) + */ +export async function waitForSends( + harness: BrokerHarness, + agent: string, + count: number, + timeoutMs: number +): Promise { + const seen = () => + harness + .getEvents() + .filter( + (e): e is Extract => + e.kind === 'relay_inbound' && e.from === agent + ).length; + const deadline = Date.now() + timeoutMs; + while (seen() < count) { + if (Date.now() >= deadline) return; + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} diff --git a/tests/integration/broker/evals/scenarios/index.ts b/tests/integration/broker/evals/scenarios/index.ts new file mode 100644 index 000000000..d532f441b --- /dev/null +++ b/tests/integration/broker/evals/scenarios/index.ts @@ -0,0 +1,39 @@ +/** + * Registry of all eval scenarios. + * + * `smoke` tier: leading prompts that name the tool — a plumbing canary. + * `realistic` tier: natural-language prompts where the protocol must come from + * the injected onboarding — the real benchmark (default). + */ +import type { EvalScenario, EvalTier } from '../types.js'; +import { scenario as dmRoundtrip } from './01-dm-roundtrip.js'; +import { scenario as channelReply } from './02-channel-reply.js'; +import { scenario as ackDone } from './03-ack-done.js'; +import { scenario as relayChain } from './04-relay-chain.js'; +import { scenario as incidentalReport } from './r01-incidental-report.js'; +import { scenario as forgetToReport } from './r02-forget-to-report.js'; +import { scenario as proactiveHandoff } from './r03-proactive-handoff.js'; +import { scenario as channelVsDm } from './r04-channel-vs-dm.js'; + +export const SCENARIOS: EvalScenario[] = [ + // smoke (plumbing canary) + dmRoundtrip, + channelReply, + ackDone, + relayChain, + // realistic (benchmark) + incidentalReport, + forgetToReport, + proactiveHandoff, + channelVsDm, +]; + +/** Look up a scenario by id. */ +export function scenarioById(id: string): EvalScenario | undefined { + return SCENARIOS.find((s) => s.id === id); +} + +/** All scenarios in a given tier. */ +export function scenariosByTier(tier: EvalTier): EvalScenario[] { + return SCENARIOS.filter((s) => s.tier === tier); +} diff --git a/tests/integration/broker/evals/scenarios/r01-incidental-report.ts b/tests/integration/broker/evals/scenarios/r01-incidental-report.ts new file mode 100644 index 000000000..b416df544 --- /dev/null +++ b/tests/integration/broker/evals/scenarios/r01-incidental-report.ts @@ -0,0 +1,66 @@ +/** + * Realistic — incidental report-back. + * + * A generic worker is given real work by the orchestrator in natural language. + * Nothing tells it which tool to use or that it must reply: the agent must learn + * to message from the production onboarding (injected skill + broker hints). + * PASS = it actually reported the result back as a DM, with no phantom. + * ACK/DONE adherence is recorded but does not gate the pass. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { scoreAckDone, sentDirectMessage } from '../scoring/protocol.js'; +import { RESPONSE_MS, STARTUP_MS, waitForSends } from './helpers.js'; + +const ROLE = + 'You are a worker agent collaborating with other agents in a shared workspace. ' + + 'Other agents will ask you to do things; handle their requests.'; + +export const scenario: EvalScenario = { + id: 'r01-incidental-report', + title: 'Incidental report-back', + tier: 'realistic', + channels: ['general'], + timeoutMs: 150_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const worker = `worker-${suffix}`; + + await harness.spawnAgent(worker, cli, ['general'], { task: ROLE }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: worker, + from: 'Orchestrator', + text: 'Can you work out the sum of all prime numbers below 30 and let me know the answer? I need it to continue.', + }); + + await waitForSends(harness, worker, 1, RESPONSE_MS); + + const events = harness.getEvents(); + const base = baseScore(events, [worker]); + const ackDone = scoreAckDone(events, worker); + const reported = sentDirectMessage(events, worker); + await harness.releaseAgent(worker).catch(() => {}); + + const pass = reported && base.phantoms.length === 0 && base.deliveryOk; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [{ name: worker, cli, role: 'worker', prompt: ROLE }], + transcript: base.transcript, + sent: base.sent, + expected: 1, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: ackDone.score, + wrongChannelReplies: 0, + deliveryOk: base.deliveryOk, + events: base.events, + notes: `repliedPrivately=${reported} (DM back to requester) · ack=${ackDone.ackPresent} done=${ackDone.donePresent}`, + }; + }, +}; diff --git a/tests/integration/broker/evals/scenarios/r02-forget-to-report.ts b/tests/integration/broker/evals/scenarios/r02-forget-to-report.ts new file mode 100644 index 000000000..52b868f17 --- /dev/null +++ b/tests/integration/broker/evals/scenarios/r02-forget-to-report.ts @@ -0,0 +1,69 @@ +/** + * Realistic — work first, report at the end (the real phantom risk). + * + * The agent is given an absorbing task (review code for bugs) with the + * coordination ask placed at the very end. The failure this catches: the agent + * does the work, writes its findings in its own output, and never actually sends + * them — a phantom. PASS = it both did the work and reported via a real send. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { sentDirectMessage } from '../scoring/protocol.js'; +import { RESPONSE_MS, STARTUP_MS, waitForSends } from './helpers.js'; + +const ROLE = 'You are a code-reviewing agent working alongside other agents in a shared workspace.'; + +const SNIPPET = + 'function lastItem(arr) { return arr[arr.length]; } ' + + 'function divide(a, b) { return a / b; }'; + +export const scenario: EvalScenario = { + id: 'r02-forget-to-report', + title: 'Work first, report at the end', + tier: 'realistic', + channels: ['general'], + timeoutMs: 180_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const worker = `reviewer-${suffix}`; + + await harness.spawnAgent(worker, cli, ['general'], { task: ROLE }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: worker, + from: 'Orchestrator', + text: + 'Please review this JavaScript for bugs: ' + + SNIPPET + + ' — list the issues you find. When you have them, send me your findings so I can open tickets.', + }); + + await waitForSends(harness, worker, 1, RESPONSE_MS); + + const events = harness.getEvents(); + const base = baseScore(events, [worker]); + const reported = sentDirectMessage(events, worker); + await harness.releaseAgent(worker).catch(() => {}); + + const pass = reported && base.phantoms.length === 0 && base.deliveryOk; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [{ name: worker, cli, role: 'reviewer', prompt: ROLE }], + transcript: base.transcript, + sent: base.sent, + expected: 1, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: null, + wrongChannelReplies: 0, + deliveryOk: base.deliveryOk, + events: base.events, + notes: `reported=${reported}`, + }; + }, +}; diff --git a/tests/integration/broker/evals/scenarios/r03-proactive-handoff.ts b/tests/integration/broker/evals/scenarios/r03-proactive-handoff.ts new file mode 100644 index 000000000..e6fa32b3f --- /dev/null +++ b/tests/integration/broker/evals/scenarios/r03-proactive-handoff.ts @@ -0,0 +1,69 @@ +/** + * Realistic — proactive hand-off to a named peer. + * + * The agent must decide, on its own, to message a teammate it was never told to + * use a tool to reach. A real `reviewer` (cat shim) is spawned so the hand-off + * has a valid target. PASS = the author proactively DM'd the reviewer. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { sentTo } from '../scoring/protocol.js'; +import { RESPONSE_MS, STARTUP_MS, waitForSends } from './helpers.js'; + +export const scenario: EvalScenario = { + id: 'r03-proactive-handoff', + title: 'Proactive hand-off to a peer', + tier: 'realistic', + channels: ['general'], + timeoutMs: 160_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const author = `author-${suffix}`; + const reviewer = `reviewer-${suffix}`; + + const role = + `You are ${author}, working with a teammate named ${reviewer} in a shared workspace. ` + + `Your job is to draft work and then get ${reviewer} to approve it before it ships.`; + + await harness.spawnAgent(reviewer, 'cat', ['general']); + await harness.spawnAgent(author, cli, ['general'], { task: role }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: author, + from: 'Orchestrator', + text: 'Write a one-sentence commit message for a change that adds dark mode, then get it approved before we ship.', + }); + + await waitForSends(harness, author, 1, RESPONSE_MS); + + const events = harness.getEvents(); + const base = baseScore(events, [author]); + const handedOff = sentTo(events, author, reviewer); + await harness.releaseAgent(author).catch(() => {}); + await harness.releaseAgent(reviewer).catch(() => {}); + + const pass = handedOff && base.phantoms.length === 0 && base.deliveryOk; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [ + { name: author, cli, role: 'author', prompt: role }, + { name: reviewer, cli: 'cat', role: 'reviewer (peer target)', prompt: '(cat shim — hand-off target)' }, + ], + transcript: base.transcript, + sent: base.sent, + expected: 1, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: null, + wrongChannelReplies: 0, + deliveryOk: base.deliveryOk, + events: base.events, + notes: `handedOffTo ${reviewer}=${handedOff}`, + }; + }, +}; diff --git a/tests/integration/broker/evals/scenarios/r04-channel-vs-dm.ts b/tests/integration/broker/evals/scenarios/r04-channel-vs-dm.ts new file mode 100644 index 000000000..55757ac83 --- /dev/null +++ b/tests/integration/broker/evals/scenarios/r04-channel-vs-dm.ts @@ -0,0 +1,69 @@ +/** + * Realistic — channel-vs-DM judgment. + * + * A question arrives in a channel. The agent is NOT told whether to reply in the + * channel or by DM; the correct behaviour (reply where the conversation is + * happening) must come from the onboarding. PASS = it answered in the channel + * and did not DM the asker instead. + */ +import type { EvalScenario, ScenarioResult } from '../types.js'; +import { baseScore } from '../scoring/base.js'; +import { scoreChannelReply } from '../scoring/protocol.js'; +import { RESPONSE_MS, STARTUP_MS, waitForSends } from './helpers.js'; + +export const scenario: EvalScenario = { + id: 'r04-channel-vs-dm', + title: 'Channel-vs-DM judgment', + tier: 'realistic', + channels: ['general'], + timeoutMs: 150_000, + run: async (ctx): Promise => { + const { harness, cli, suffix, sleep } = ctx; + const worker = `teammate-${suffix}`; + const channel = `standup-${suffix}`; + + const role = + `You are ${worker}, a member of the #${channel} channel where the team coordinates. ` + + `Participate in the channel's discussion as a normal team member would.`; + + await harness.spawnAgent(worker, cli, [channel], { task: role }); + await sleep(STARTUP_MS); + harness.clearEvents(); + + await harness.sendMessage({ + to: worker, + from: 'Maya', + text: `Relay message from Maya [q-${suffix}] [#${channel}]: Quick standup question — what's 12 times 9? Drop the answer here so everyone sees it.`, + }); + + await waitForSends(harness, worker, 1, RESPONSE_MS); + + const events = harness.getEvents(); + const base = baseScore(events, [worker]); + const ch = scoreChannelReply(events, worker, channel); + await harness.releaseAgent(worker).catch(() => {}); + + const pass = + ch.repliedToShownChannel && + ch.wrongChannelReplies === 0 && + base.phantoms.length === 0 && + base.deliveryOk; + + return { + id: scenario.id, + title: scenario.title, + pass, + agents: [{ name: worker, cli, role: `member of #${channel}`, prompt: role }], + transcript: base.transcript, + sent: base.sent, + expected: 1, + phantoms: base.phantoms, + totalIntents: base.totalIntents, + protocolAdherence: ch.repliedToShownChannel ? 1 : 0, + wrongChannelReplies: ch.wrongChannelReplies, + deliveryOk: base.deliveryOk, + events: base.events, + notes: `inChannel=${ch.repliedToShownChannel} wrongChannel=${ch.wrongChannelReplies}`, + }; + }, +}; diff --git a/tests/integration/broker/evals/scoring/base.ts b/tests/integration/broker/evals/scoring/base.ts new file mode 100644 index 000000000..ae034e013 --- /dev/null +++ b/tests/integration/broker/evals/scoring/base.ts @@ -0,0 +1,72 @@ +/** + * Shared scoring building blocks every scenario reuses: actual-send counts, + * phantom detection, and delivery health derived from the broker event stream. + */ +import type { BrokerEvent } from '@agent-relay/harness-driver'; + +import type { ScenarioResult, TranscriptEntry } from '../types.js'; +import { detectPhantoms } from './phantom.js'; + +/** + * Build the message transcript from `relay_inbound` events — every message that + * reached the broker, in order. Entries whose sender is an agent under test are + * real tool-backed sends; the rest are the injected stimulus. + */ +export function buildTranscript(events: BrokerEvent[], agents: string[]): TranscriptEntry[] { + const set = new Set(agents); + return events + .filter((e): e is Extract => e.kind === 'relay_inbound') + .map((e) => ({ + from: e.from, + target: e.target, + body: e.body, + fromAgent: set.has(e.from), + threadId: e.thread_id, + })); +} + +/** Count `relay_inbound` events originating from any of the given agents. */ +export function countSends(events: BrokerEvent[], agents: string[]): number { + const set = new Set(agents); + return events.filter((e) => e.kind === 'relay_inbound' && set.has(e.from)).length; +} + +/** Coarse delivery-health counts for the report. */ +export function deliveryCounts(events: BrokerEvent[]): { + relayInbound: number; + dropped: number; + aclDenied: number; +} { + return { + relayInbound: events.filter((e) => e.kind === 'relay_inbound').length, + dropped: events.filter((e) => e.kind === 'delivery_dropped').length, + aclDenied: events.filter((e) => e.kind === 'acl_denied').length, + }; +} + +export interface BaseScore { + sent: number; + phantoms: ScenarioResult['phantoms']; + totalIntents: number; + deliveryOk: boolean; + events: ScenarioResult['events']; + transcript: TranscriptEntry[]; +} + +/** + * Compute the scenario-agnostic signals: how many messages the agents actually + * sent, what phantom messages they emitted, whether delivery stayed clean, and + * the message transcript. + */ +export function baseScore(events: BrokerEvent[], agents: string[]): BaseScore { + const phantomResult = detectPhantoms(events, agents); + const counts = deliveryCounts(events); + return { + sent: countSends(events, agents), + phantoms: phantomResult.phantoms, + totalIntents: phantomResult.totalIntents, + deliveryOk: counts.dropped === 0 && counts.aclDenied === 0, + events: counts, + transcript: buildTranscript(events, agents), + }; +} diff --git a/tests/integration/broker/evals/scoring/fixtures.ts b/tests/integration/broker/evals/scoring/fixtures.ts new file mode 100644 index 000000000..f0a9ef1be --- /dev/null +++ b/tests/integration/broker/evals/scoring/fixtures.ts @@ -0,0 +1,22 @@ +/** + * Synthetic BrokerEvent builders for scorer unit tests. + */ +import type { BrokerEvent } from '@agent-relay/harness-driver'; + +let counter = 0; + +/** A worker_stream chunk emitted by an agent. */ +export function stream(name: string, chunk: string): BrokerEvent { + return { kind: 'worker_stream', name, stream: 'stdout', chunk }; +} + +/** A relay_inbound event = an agent actually sent a message. */ +export function inbound(from: string, target: string, body = ''): BrokerEvent { + counter += 1; + return { kind: 'relay_inbound', event_id: `evt-${counter}`, from, target, body }; +} + +/** A delivery_dropped event. */ +export function dropped(name: string, reason = 'test'): BrokerEvent { + return { kind: 'delivery_dropped', name, count: 1, reason }; +} diff --git a/tests/integration/broker/evals/scoring/metrics.ts b/tests/integration/broker/evals/scoring/metrics.ts new file mode 100644 index 000000000..97a3b4a33 --- /dev/null +++ b/tests/integration/broker/evals/scoring/metrics.ts @@ -0,0 +1,48 @@ +/** + * Aggregate per-scenario results into a single MetricSet for a harness. + */ +import type { MetricSet, ScenarioResult } from '../types.js'; + +function rate(numerator: number, denominator: number): number { + if (denominator <= 0) return 1; + return Math.min(1, numerator / denominator); +} + +/** Roll up scenario results into the headline metrics for one harness. */ +export function aggregateMetrics(results: ScenarioResult[]): MetricSet { + const total = results.length; + let sent = 0; + let expected = 0; + let phantomCount = 0; + let totalIntents = 0; + let wrongChannelReplies = 0; + let deliveryOkCount = 0; + let adherenceSum = 0; + let adherenceCount = 0; + let scenariosPassed = 0; + + for (const r of results) { + sent += r.sent; + expected += r.expected; + phantomCount += r.phantoms.length; + totalIntents += r.totalIntents; + wrongChannelReplies += r.wrongChannelReplies; + if (r.deliveryOk) deliveryOkCount += 1; + if (r.protocolAdherence !== null) { + adherenceSum += r.protocolAdherence; + adherenceCount += 1; + } + if (r.pass) scenariosPassed += 1; + } + + return { + messageSentRate: rate(sent, expected), + phantomRate: totalIntents > 0 ? phantomCount / totalIntents : 0, + phantomCount, + protocolAdherence: adherenceCount > 0 ? adherenceSum / adherenceCount : 1, + deliverySuccessRate: rate(deliveryOkCount, total), + wrongChannelReplies, + scenariosPassed, + scenariosTotal: total, + }; +} diff --git a/tests/integration/broker/evals/scoring/metrics.unit.test.ts b/tests/integration/broker/evals/scoring/metrics.unit.test.ts new file mode 100644 index 000000000..a46880174 --- /dev/null +++ b/tests/integration/broker/evals/scoring/metrics.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { aggregateMetrics } from './metrics.js'; +import type { ScenarioResult } from '../types.js'; + +function result(overrides: Partial): ScenarioResult { + return { + id: 'x', + title: 'x', + pass: true, + sent: 1, + expected: 1, + phantoms: [], + totalIntents: 0, + protocolAdherence: null, + wrongChannelReplies: 0, + deliveryOk: true, + events: { relayInbound: 1, dropped: 0, aclDenied: 0 }, + ...overrides, + }; +} + +describe('aggregateMetrics', () => { + it('computes rates across scenarios', () => { + const m = aggregateMetrics([ + result({ sent: 1, expected: 1, pass: true }), + result({ sent: 1, expected: 2, pass: false }), + ]); + expect(m.messageSentRate).toBeCloseTo(2 / 3); + expect(m.scenariosPassed).toBe(1); + expect(m.scenariosTotal).toBe(2); + expect(m.deliverySuccessRate).toBe(1); + }); + + it('phantom rate is phantomCount / totalIntents', () => { + const m = aggregateMetrics([ + result({ + totalIntents: 3, + phantoms: [ + { agent: 'A', verb: 'tell', snippet: '' }, + { agent: 'A', verb: 'post', snippet: '' }, + ], + }), + ]); + expect(m.phantomCount).toBe(2); + expect(m.phantomRate).toBeCloseTo(2 / 3); + }); + + it('phantom rate is 0 when no intents were expressed', () => { + expect(aggregateMetrics([result({ totalIntents: 0, phantoms: [] })]).phantomRate).toBe(0); + }); + + it('protocol adherence averages only applicable scenarios', () => { + const m = aggregateMetrics([ + result({ protocolAdherence: 1 }), + result({ protocolAdherence: 0.5 }), + result({ protocolAdherence: null }), + ]); + expect(m.protocolAdherence).toBeCloseTo(0.75); + }); + + it('delivery failures lower the success rate', () => { + const m = aggregateMetrics([ + result({ deliveryOk: true }), + result({ deliveryOk: false }), + ]); + expect(m.deliverySuccessRate).toBe(0.5); + }); +}); diff --git a/tests/integration/broker/evals/scoring/phantom.ts b/tests/integration/broker/evals/scoring/phantom.ts new file mode 100644 index 000000000..366ea8d56 --- /dev/null +++ b/tests/integration/broker/evals/scoring/phantom.ts @@ -0,0 +1,177 @@ +/** + * Phantom-message detection. + * + * A "phantom" is the failure we most want to catch: an agent stating in plain + * text that it will communicate ("I'll tell Lead the result") without ever + * invoking a messaging tool. Ground truth for an actual send is a + * `relay_inbound` event whose `from` is the agent; intent lives in the agent's + * `worker_stream` prose. + * + * Detection is intentionally forward-looking: only future-tense / present- + * continuous phrasings are treated as intent, so past-tense narration ("I sent + * the code", "told B") does not produce false positives. + */ +import type { BrokerEvent } from '@agent-relay/harness-driver'; + +import type { Phantom } from '../types.js'; +import { cleanStreamOutput } from './stream-clean.js'; + +export interface IntentSpan { + verb: string; + target?: string; + offset: number; + snippet: string; +} + +interface IntentPattern { + re: RegExp; + verbGroup: number; + targetGroup?: number; +} + +/** + * Forward-looking intent patterns. Each is global + case-insensitive. The verb + * group identifies the communication action; the optional target group captures + * who/what the agent said it would contact. + */ +const INTENT_PATTERNS: IntentPattern[] = [ + // "I'll / I will / I'm going to / going to / let me / I can" + comm verb + optional target + { + re: /\b(?:i'?ll|i will|i'?m going to|going to|let me|i can|i should)\s+(tell|message|dm|notify|reply to|respond to|post|send|report(?:\s+to)?|relay(?:\s+to)?|forward(?:\s+to)?|ping|update|let)\s+(?:to\s+|the\s+)?([a-z0-9_#-]+)?/gi, + verbGroup: 1, + targetGroup: 2, + }, + // present-continuous narration: "sending / posting / messaging X" + { + re: /\b(sending|posting|messaging|replying to|responding to|notifying|reporting to|relaying to|forwarding to|pinging|dming)\s+(?:to\s+|the\s+)?([a-z0-9_#-]+)?/gi, + verbGroup: 1, + targetGroup: 2, + }, +]; + +/** Negations immediately before a comm verb that cancel the intent. */ +const NEGATION_BEFORE = /\b(?:without|not|never|don'?t|do not|didn'?t|avoid|instead of|rather than|no need to)\s*$/i; + +/** Words that follow a comm verb but are not real targets (filtered out). */ +const TARGET_STOPWORDS = new Set([ + 'the', + 'a', + 'an', + 'this', + 'that', + 'you', + 'them', + 'it', + 'back', + 'now', + 'them', + 'everyone', + 'and', + 'with', + 'about', + 'my', + 'our', + 'using', + 'via', + 'to', +]); + +function normalizeTarget(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const t = raw.replace(/^[#@]/, '').trim().toLowerCase(); + if (!t || TARGET_STOPWORDS.has(t)) return undefined; + return t; +} + +/** Extract forward-looking intent spans from an agent's cleaned output. */ +export function detectIntents(cleanText: string): IntentSpan[] { + const spans: IntentSpan[] = []; + for (const { re, verbGroup, targetGroup } of INTENT_PATTERNS) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(cleanText)) !== null) { + // Skip negated phrasings ("without sending", "don't post"). + if (NEGATION_BEFORE.test(cleanText.slice(Math.max(0, m.index - 16), m.index))) continue; + const verb = m[verbGroup]?.toLowerCase() ?? ''; + const target = targetGroup ? normalizeTarget(m[targetGroup]) : undefined; + const start = Math.max(0, m.index - 10); + const snippet = cleanText.slice(start, m.index + m[0].length + 30).replace(/\s+/g, ' ').trim(); + spans.push({ verb, target, offset: m.index, snippet }); + } + } + // De-duplicate near-identical matches (two patterns hitting the same phrase). + spans.sort((a, b) => a.offset - b.offset); + const deduped: IntentSpan[] = []; + for (const span of spans) { + const prev = deduped[deduped.length - 1]; + if (prev && Math.abs(prev.offset - span.offset) < 4) continue; + deduped.push(span); + } + return deduped; +} + +interface InboundSend { + target: string; +} + +function relayInboundFrom(events: BrokerEvent[], agent: string): InboundSend[] { + return events + .filter((e): e is Extract => e.kind === 'relay_inbound') + .filter((e) => e.from === agent) + .map((e) => ({ target: e.target })); +} + +export interface PhantomResult { + phantoms: Phantom[]; + totalIntents: number; + satisfiedIntents: number; +} + +/** + * Detect phantom messages for a single agent by correlating intent spans + * against actual sends. + * + * Each intent is satisfied greedily: first by an unconsumed send to the same + * target (when the intent named one), otherwise by any unconsumed send (the + * agent used the tool, just for something else). An intent with no satisfying + * send is a phantom — the agent said it would message but never invoked a tool. + */ +export function detectPhantomsForAgent(events: BrokerEvent[], agent: string): PhantomResult { + const intents = detectIntents(cleanStreamOutput(events, agent)); + const sends = relayInboundFrom(events, agent); + const consumed = new Array(sends.length).fill(false); + const phantoms: Phantom[] = []; + let satisfied = 0; + + for (const intent of intents) { + let matchIdx = -1; + if (intent.target) { + matchIdx = sends.findIndex( + (s, i) => !consumed[i] && normalizeTarget(s.target) === intent.target + ); + } + if (matchIdx === -1) { + matchIdx = sends.findIndex((_, i) => !consumed[i]); + } + if (matchIdx === -1) { + phantoms.push({ agent, verb: intent.verb, target: intent.target, snippet: intent.snippet }); + } else { + consumed[matchIdx] = true; + satisfied += 1; + } + } + + return { phantoms, totalIntents: intents.length, satisfiedIntents: satisfied }; +} + +/** Aggregate phantom detection across multiple agents. */ +export function detectPhantoms(events: BrokerEvent[], agents: string[]): PhantomResult { + const all: PhantomResult = { phantoms: [], totalIntents: 0, satisfiedIntents: 0 }; + for (const agent of agents) { + const r = detectPhantomsForAgent(events, agent); + all.phantoms.push(...r.phantoms); + all.totalIntents += r.totalIntents; + all.satisfiedIntents += r.satisfiedIntents; + } + return all; +} diff --git a/tests/integration/broker/evals/scoring/phantom.unit.test.ts b/tests/integration/broker/evals/scoring/phantom.unit.test.ts new file mode 100644 index 000000000..8a6c9b1f6 --- /dev/null +++ b/tests/integration/broker/evals/scoring/phantom.unit.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { detectIntents, detectPhantomsForAgent } from './phantom.js'; +import { inbound, stream } from './fixtures.js'; + +describe('detectIntents', () => { + it('detects forward-looking intent with a target', () => { + const spans = detectIntents("Okay, I'll tell Lead the result now."); + expect(spans).toHaveLength(1); + expect(spans[0].verb).toBe('tell'); + expect(spans[0].target).toBe('lead'); + }); + + it('detects present-continuous narration', () => { + const spans = detectIntents('Messaging Bob about the result.'); + expect(spans.length).toBeGreaterThanOrEqual(1); + expect(spans[0].verb).toBe('messaging'); + expect(spans[0].target).toBe('bob'); + }); + + it('ignores past-tense narration', () => { + expect(detectIntents('I told Bob the answer and I sent the code.')).toHaveLength(0); + }); + + it('ignores negated phrasings', () => { + expect(detectIntents('I wrote the text without sending it.')).toHaveLength(0); + expect(detectIntents("Do not post to the channel.")).toHaveLength(0); + }); + + it('filters stopword targets', () => { + const spans = detectIntents("I'll reply to you shortly."); + expect(spans).toHaveLength(1); + expect(spans[0].target).toBeUndefined(); + }); +}); + +describe('detectPhantomsForAgent', () => { + it('no phantom when intent is backed by a matching send', () => { + const events = [ + stream('Bob', "I'll reply to Alice with PONG."), + inbound('Bob', 'Alice', 'PONG'), + ]; + const r = detectPhantomsForAgent(events, 'Bob'); + expect(r.totalIntents).toBe(1); + expect(r.phantoms).toHaveLength(0); + expect(r.satisfiedIntents).toBe(1); + }); + + it('flags a phantom when intent has no send', () => { + const events = [stream('Bob', "I'll tell Alice the result.")]; + const r = detectPhantomsForAgent(events, 'Bob'); + expect(r.phantoms).toHaveLength(1); + expect(r.phantoms[0].agent).toBe('Bob'); + expect(r.phantoms[0].target).toBe('alice'); + }); + + it('a targeted intent is satisfied by any send (used the tool, wrong target)', () => { + const events = [ + stream('Bob', "I'll tell Lead the status."), + inbound('Bob', 'SomeoneElse', 'status'), + ]; + expect(detectPhantomsForAgent(events, 'Bob').phantoms).toHaveLength(0); + }); + + it('flags the unbacked intent when there are more intents than sends', () => { + const events = [ + stream('Bob', "I'll message Alice. Then I will notify Carol."), + inbound('Bob', 'Alice', 'hi'), + ]; + const r = detectPhantomsForAgent(events, 'Bob'); + expect(r.totalIntents).toBe(2); + expect(r.phantoms).toHaveLength(1); + expect(r.phantoms[0].target).toBe('carol'); + }); + + it('zero intents and zero sends yields no phantoms', () => { + const events = [stream('Bob', 'Just thinking out loud about the problem.')]; + const r = detectPhantomsForAgent(events, 'Bob'); + expect(r.totalIntents).toBe(0); + expect(r.phantoms).toHaveLength(0); + }); + + it('strips ANSI before matching', () => { + const events = [stream('Bob', "\x1b[32mI'll post READY to #general\x1b[0m")]; + const r = detectPhantomsForAgent(events, 'Bob'); + expect(r.totalIntents).toBe(1); + expect(r.phantoms).toHaveLength(1); + }); +}); diff --git a/tests/integration/broker/evals/scoring/protocol.ts b/tests/integration/broker/evals/scoring/protocol.ts new file mode 100644 index 000000000..fa342d712 --- /dev/null +++ b/tests/integration/broker/evals/scoring/protocol.ts @@ -0,0 +1,123 @@ +/** + * Deterministic protocol checks over `relay_inbound` events. + * + * Encodes the rules the injected skill tells agents to follow + * (`.claude/skills/using-agent-relay/SKILL.md`): ACK on task receipt before + * reporting DONE, and reply in the channel shown rather than DMing the sender. + */ +import type { BrokerEvent } from '@agent-relay/harness-driver'; + +type RelayInbound = Extract; + +function inboundFrom(events: BrokerEvent[], agent: string): RelayInbound[] { + return events + .filter((e): e is RelayInbound => e.kind === 'relay_inbound') + .filter((e) => e.from === agent); +} + +function normalizeChannel(target: string): string { + return target.replace(/^[#@]/, '').trim().toLowerCase(); +} + +function isChannelTarget(target: string): boolean { + return target.startsWith('#'); +} + +export interface AckDoneResult { + ackPresent: boolean; + donePresent: boolean; + orderOk: boolean; + /** (ackPresent + donePresent + orderOk) / 3 */ + score: number; +} + +/** + * Score the ACK/DONE protocol for a worker reporting status to its lead. + * ACK must appear before DONE in the agent's send order. + * + * The protocol rule is "report privately to the lead, not broadcast to a + * channel", so this counts the worker's direct messages (non-channel targets). + * It deliberately does not require a literal lead name: when the lead is + * simulated by the harness, the reply target resolves to the harness identity, + * but it is still a DM. Channel posts (target starts with `#`) are excluded. + */ +export function scoreAckDone(events: BrokerEvent[], worker: string): AckDoneResult { + const sends = inboundFrom(events, worker).filter((e) => !isChannelTarget(e.target)); + const ackIdx = sends.findIndex((e) => /^\s*ack\b/i.test(e.body)); + const doneIdx = sends.findIndex((e) => /^\s*done\b/i.test(e.body)); + const ackPresent = ackIdx !== -1; + const donePresent = doneIdx !== -1; + const orderOk = ackPresent && donePresent && ackIdx < doneIdx; + const score = (Number(ackPresent) + Number(donePresent) + Number(orderOk)) / 3; + return { ackPresent, donePresent, orderOk, score }; +} + +export interface ChannelReplyResult { + /** Replied at least once in the expected channel. */ + repliedToShownChannel: boolean; + /** Sends that went to a DM or a different channel than expected. */ + wrongChannelReplies: number; +} + +/** + * Score whether `agent` replied in `expectedChannel` rather than DMing the + * sender or posting elsewhere. Any send whose target is not the expected + * channel counts as a wrong-channel reply. + */ +export function scoreChannelReply( + events: BrokerEvent[], + agent: string, + expectedChannel: string +): ChannelReplyResult { + const expected = normalizeChannel(expectedChannel); + const sends = inboundFrom(events, agent); + let repliedToShownChannel = false; + let wrongChannelReplies = 0; + for (const s of sends) { + const target = normalizeChannel(s.target); + if (isChannelTarget(s.target) && target === expected) { + repliedToShownChannel = true; + } else { + wrongChannelReplies += 1; + } + } + return { repliedToShownChannel, wrongChannelReplies }; +} + +/** True if `agent` sent at least one direct message (non-channel target). */ +export function sentDirectMessage(events: BrokerEvent[], agent: string): boolean { + return inboundFrom(events, agent).some((e) => !isChannelTarget(e.target)); +} + +/** True if `agent` sent at least one message whose target matches `name`. */ +export function sentTo(events: BrokerEvent[], agent: string, name: string): boolean { + return inboundFrom(events, agent).some((e) => normalizeChannel(e.target) === normalizeChannel(name)); +} + +/** + * Trace a relay chain: each hop is an agent sending to the next. Returns how + * many hops completed and whether the payload survived to the final target. + */ +export function scoreRelayChain( + events: BrokerEvent[], + hops: Array<{ from: string; to: string }>, + payload: string, + finalChannel: string +): { hopsCompleted: number; payloadIntact: boolean } { + let hopsCompleted = 0; + for (const hop of hops) { + const sent = inboundFrom(events, hop.from).some( + (e) => normalizeChannel(e.target) === normalizeChannel(hop.to) + ); + if (sent) hopsCompleted += 1; + else break; + } + const finalPost = events + .filter((e): e is RelayInbound => e.kind === 'relay_inbound') + .find( + (e) => + normalizeChannel(e.target) === normalizeChannel(finalChannel) && + e.body.includes(payload) + ); + return { hopsCompleted, payloadIntact: Boolean(finalPost) }; +} diff --git a/tests/integration/broker/evals/scoring/protocol.unit.test.ts b/tests/integration/broker/evals/scoring/protocol.unit.test.ts new file mode 100644 index 000000000..b4f98f342 --- /dev/null +++ b/tests/integration/broker/evals/scoring/protocol.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { scoreAckDone, scoreChannelReply, scoreRelayChain } from './protocol.js'; +import { inbound } from './fixtures.js'; + +describe('scoreAckDone', () => { + it('full credit for ACK then DONE as DMs', () => { + const events = [ + inbound('Worker', 'Lead', 'ACK: starting now'), + inbound('Worker', 'Lead', 'DONE: result is 4'), + ]; + const r = scoreAckDone(events, 'Worker'); + expect(r).toMatchObject({ ackPresent: true, donePresent: true, orderOk: true }); + expect(r.score).toBe(1); + }); + + it('counts DMs to the harness identity (simulated lead), not just a literal name', () => { + const events = [ + inbound('Worker', 'test-harness-abc', 'ACK: starting now'), + inbound('Worker', 'test-harness-abc', 'DONE: result is 4'), + ]; + expect(scoreAckDone(events, 'Worker').score).toBe(1); + }); + + it('partial credit when DONE precedes ACK', () => { + const events = [ + inbound('Worker', 'Lead', 'DONE: result is 4'), + inbound('Worker', 'Lead', 'ACK: starting now'), + ]; + const r = scoreAckDone(events, 'Worker'); + expect(r.orderOk).toBe(false); + expect(r.score).toBeCloseTo(2 / 3); + }); + + it('one third credit when only ACK is present', () => { + const events = [inbound('Worker', 'Lead', 'ACK: starting now')]; + expect(scoreAckDone(events, 'Worker').score).toBeCloseTo(1 / 3); + }); + + it('excludes channel posts (status must be a DM, not broadcast)', () => { + const events = [ + inbound('Worker', '#general', 'ACK: starting now'), + inbound('Worker', '#general', 'DONE: result is 4'), + ]; + expect(scoreAckDone(events, 'Worker').score).toBe(0); + }); +}); + +describe('scoreChannelReply', () => { + it('credits a reply in the expected channel', () => { + const events = [inbound('Worker', '#proj-x', 'READY')]; + expect(scoreChannelReply(events, 'Worker', 'proj-x')).toEqual({ + repliedToShownChannel: true, + wrongChannelReplies: 0, + }); + }); + + it('counts a DM to the sender as a wrong-channel reply', () => { + const events = [inbound('Worker', 'Lead', 'READY')]; + expect(scoreChannelReply(events, 'Worker', 'proj-x')).toEqual({ + repliedToShownChannel: false, + wrongChannelReplies: 1, + }); + }); + + it('counts a post to a different channel as wrong', () => { + const events = [ + inbound('Worker', '#proj-x', 'READY'), + inbound('Worker', '#random', 'oops'), + ]; + expect(scoreChannelReply(events, 'Worker', 'proj-x')).toEqual({ + repliedToShownChannel: true, + wrongChannelReplies: 1, + }); + }); +}); + +describe('scoreRelayChain', () => { + const hops = [ + { from: 'A', to: 'B' }, + { from: 'B', to: 'C' }, + { from: 'C', to: '#general' }, + ]; + + it('full chain with intact payload', () => { + const events = [ + inbound('A', 'B', 'code GH-1234'), + inbound('B', 'C', 'code GH-1234'), + inbound('C', '#general', 'FINAL: GH-1234'), + ]; + expect(scoreRelayChain(events, hops, 'GH-1234', '#general')).toEqual({ + hopsCompleted: 3, + payloadIntact: true, + }); + }); + + it('stops counting at the first broken hop', () => { + const events = [inbound('A', 'B', 'code GH-1234')]; + expect(scoreRelayChain(events, hops, 'GH-1234', '#general')).toEqual({ + hopsCompleted: 1, + payloadIntact: false, + }); + }); + + it('payload corruption fails the intact check', () => { + const events = [ + inbound('A', 'B', 'code GH-1234'), + inbound('B', 'C', 'code GH-1234'), + inbound('C', '#general', 'FINAL: WRONG'), + ]; + expect(scoreRelayChain(events, hops, 'GH-1234', '#general').payloadIntact).toBe(false); + }); +}); diff --git a/tests/integration/broker/evals/scoring/stream-clean.ts b/tests/integration/broker/evals/scoring/stream-clean.ts new file mode 100644 index 000000000..1e957188b --- /dev/null +++ b/tests/integration/broker/evals/scoring/stream-clean.ts @@ -0,0 +1,31 @@ +/** + * Strip terminal control sequences from raw PTY output so the phantom-message + * detector matches on readable text. + * + * Agent CLIs emit ANSI SGR colour codes, OSC title/hyperlink sequences, and + * private-mode (`?`) cursor/screen toggles interleaved with their prose. The + * same regex set is used by the broker integration tests to assert on agent + * output; centralising it here keeps the detector and those tests in sync. + */ +import type { BrokerEvent } from '@agent-relay/harness-driver'; + +import { eventsForAgent } from '../../utils/assert-helpers.js'; + +/** Matches CSI sequences, OSC sequences, and private-mode toggles. */ +const ANSI_PATTERN = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\[[?][0-9]*[a-z]/g; + +/** Remove ANSI/OSC control sequences from a string. */ +export function stripAnsi(input: string): string { + return input.replace(ANSI_PATTERN, ''); +} + +/** Concatenate all `worker_stream` chunks emitted by an agent. */ +export function collectStreamOutput(events: BrokerEvent[], agentName: string): string { + const streams = eventsForAgent(events, agentName, 'worker_stream'); + return streams.map((ev) => (ev as BrokerEvent & { chunk: string }).chunk).join(''); +} + +/** Convenience: collect + strip an agent's raw output into clean text. */ +export function cleanStreamOutput(events: BrokerEvent[], agentName: string): string { + return stripAnsi(collectStreamOutput(events, agentName)); +} diff --git a/tests/integration/broker/evals/scoring/toolcheck.ts b/tests/integration/broker/evals/scoring/toolcheck.ts new file mode 100644 index 000000000..b3c5d1b3b --- /dev/null +++ b/tests/integration/broker/evals/scoring/toolcheck.ts @@ -0,0 +1,75 @@ +/** + * Wrong-tool-name trap (deterministic). + * + * Onboarding that tells agents to call a tool the MCP server doesn't register is + * a real, silent failure mode: the agent "messages" but nothing is sent. This + * cross-references the tool names referenced in onboarding docs against the names + * the server actually registers, and flags any that an agent could not call. + * + * Pure functions only — the CLI (`toolcheck-cli.ts`) supplies the real inputs. + */ +export interface ToolRef { + raw: string; + prefix: string; + name: string; +} + +export interface ToolMismatch { + raw: string; + reason: string; +} + +export interface ToolCheckResult { + registered: string[]; + referenced: ToolRef[]; + mismatches: ToolMismatch[]; + ok: boolean; +} + +/** Parse `mcp____` into its parts. */ +export function parseToolRef(raw: string): ToolRef | null { + const m = /^mcp__([a-z0-9-]+)__([a-z0-9_]+)$/.exec(raw); + if (!m) return null; + return { raw, prefix: m[1], name: m[2] }; +} + +/** Extract every `mcp__*__*` reference from arbitrary text. */ +export function extractToolRefs(text: string): string[] { + return Array.from(text.matchAll(/mcp__[a-z0-9-]+__[a-z0-9_]+/g), (m) => m[0]); +} + +/** + * Check referenced tool names against the registered set. A reference fails if + * its server prefix is not `serverName`, or its action name is not registered. + */ +export function checkToolNames( + registered: string[], + referencedRaw: string[], + serverName = 'agent-relay' +): ToolCheckResult { + const regSet = new Set(registered); + const seen = new Set(); + const referenced: ToolRef[] = []; + const mismatches: ToolMismatch[] = []; + + for (const raw of referencedRaw) { + if (seen.has(raw)) continue; + seen.add(raw); + const ref = parseToolRef(raw); + if (!ref) { + mismatches.push({ raw, reason: 'unparseable tool reference' }); + continue; + } + referenced.push(ref); + if (ref.prefix !== serverName) { + mismatches.push({ + raw, + reason: `wrong server prefix "${ref.prefix}" (registered server is "${serverName}")`, + }); + } else if (!regSet.has(ref.name)) { + mismatches.push({ raw, reason: `no such tool "${ref.name}" on the server` }); + } + } + + return { registered, referenced, mismatches, ok: mismatches.length === 0 }; +} diff --git a/tests/integration/broker/evals/scoring/toolcheck.unit.test.ts b/tests/integration/broker/evals/scoring/toolcheck.unit.test.ts new file mode 100644 index 000000000..11d9ea1bb --- /dev/null +++ b/tests/integration/broker/evals/scoring/toolcheck.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { checkToolNames, extractToolRefs, parseToolRef } from './toolcheck.js'; + +describe('parseToolRef', () => { + it('splits prefix and name', () => { + expect(parseToolRef('mcp__agent-relay__send_dm')).toEqual({ + raw: 'mcp__agent-relay__send_dm', + prefix: 'agent-relay', + name: 'send_dm', + }); + }); + it('rejects non-tool strings', () => { + expect(parseToolRef('send_dm')).toBeNull(); + }); +}); + +describe('extractToolRefs', () => { + it('pulls all references from text', () => { + const text = 'Use mcp__agent-relay__send_dm or mcp__relaycast__message_post here.'; + expect(extractToolRefs(text)).toEqual([ + 'mcp__agent-relay__send_dm', + 'mcp__relaycast__message_post', + ]); + }); +}); + +describe('checkToolNames', () => { + const registered = ['send_dm', 'post_message', 'check_inbox']; + + it('passes when every reference maps to a registered tool', () => { + const r = checkToolNames(registered, ['mcp__agent-relay__send_dm', 'mcp__agent-relay__post_message']); + expect(r.ok).toBe(true); + expect(r.mismatches).toHaveLength(0); + }); + + it('flags the wrong server prefix (the real skill bug)', () => { + const r = checkToolNames(registered, ['mcp__relaycast__message_dm_send']); + expect(r.ok).toBe(false); + expect(r.mismatches[0].reason).toMatch(/wrong server prefix/); + }); + + it('flags a correct prefix but unregistered action name', () => { + const r = checkToolNames(registered, ['mcp__agent-relay__message_dm_send']); + expect(r.ok).toBe(false); + expect(r.mismatches[0].reason).toMatch(/no such tool/); + }); + + it('dedupes repeated references', () => { + const r = checkToolNames(registered, [ + 'mcp__agent-relay__send_dm', + 'mcp__agent-relay__send_dm', + ]); + expect(r.referenced).toHaveLength(1); + }); +}); diff --git a/tests/integration/broker/evals/selftest.ts b/tests/integration/broker/evals/selftest.ts new file mode 100644 index 000000000..5c7dfbd74 --- /dev/null +++ b/tests/integration/broker/evals/selftest.ts @@ -0,0 +1,80 @@ +/** + * Eval self-test (negative control). + * + * Proves the eval can go RED, not just green: it spawns an agent that has no + * messaging capability at all (the `cat` shim — a real broker with real message + * injection and event capture, but a process that can never call an MCP/CLI + * tool). A sound eval must then observe zero `relay_inbound` and score the + * interaction as a failure. If `cat` somehow registered a send, the eval would + * be reporting false greens — and this self-test fails loudly. + * + * Run: + * npm run eval:build + * node tests/integration/broker/dist/evals/selftest.js + * + * Needs the agent-relay-broker binary and Relaycast workspace access. `cat` is + * always available, so this requires no real LLM CLI and costs no tokens. + */ +import { BrokerHarness, checkPrerequisites, uniqueSuffix } from '../utils/broker-harness.js'; +import { sleep } from '../utils/cli-helpers.js'; +import { baseScore } from './scoring/base.js'; + +async function main(): Promise { + const prereq = checkPrerequisites(); + if (prereq) { + console.error(`Cannot run self-test: ${prereq}`); + process.exit(2); + } + + const harness = new BrokerHarness({ channels: ['general'] }); + await harness.start(); + const agent = `noop-${uniqueSuffix()}`; + + try { + // A real agent process that physically cannot send via MCP/CLI. + await harness.spawnAgent(agent, 'cat', ['general'], { + task: 'reply to the sender', + }); + await sleep(8_000); + harness.clearEvents(); + + await harness.sendMessage({ + to: agent, + from: 'Alice', + text: 'Ping — please reply to me with PONG.', + }); + await sleep(10_000); + + const events = harness.getEvents(); + const score = baseScore(events, [agent]); + + // The eval would score this scenario as: did the agent actually send? No. + const evalWouldPass = score.sent >= 1 && score.phantoms.length === 0 && score.deliveryOk; + + console.log( + `negative control: sent=${score.sent} phantoms=${score.phantoms.length} ` + + `relay_inbound=${score.events.relayInbound} → eval ${evalWouldPass ? 'PASS' : 'FAIL'}` + ); + + if (evalWouldPass) { + console.error( + '✗ SELF-TEST FAILED: the eval reported PASS for an agent that cannot message. ' + + 'The eval is producing false greens.' + ); + process.exit(1); + } + if (score.sent !== 0) { + console.error(`✗ SELF-TEST FAILED: expected 0 real sends from a no-op agent, got ${score.sent}.`); + process.exit(1); + } + console.log('✓ SELF-TEST PASSED: the eval correctly flags a broken/absent messaging path as FAIL.'); + } finally { + await harness.releaseAgent(agent).catch(() => {}); + await harness.stop().catch(() => {}); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/integration/broker/evals/toolcheck-cli.ts b/tests/integration/broker/evals/toolcheck-cli.ts new file mode 100644 index 000000000..6e2810026 --- /dev/null +++ b/tests/integration/broker/evals/toolcheck-cli.ts @@ -0,0 +1,61 @@ +/** + * Wrong-tool-name trap (CLI). + * + * Reads the tool names the MCP server registers and the names the + * using-agent-relay onboarding skill tells agents to call, then reports any tool + * an agent could not actually invoke. Exits non-zero on a mismatch. + * + * npm run eval:toolcheck + * + * Deterministic — no broker, no agents, no tokens. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +import { checkToolNames, extractToolRefs } from './scoring/toolcheck.js'; + +function repoRoot(): string { + // dist/evals/toolcheck-cli.js → up 5 to repo root. + return path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../../..'); +} + +/** Registered tool names = first string arg of each `server.registerTool(`. */ +function registeredToolNames(mcpSource: string): string[] { + const names = new Set(); + const re = /registerTool\(\s*['"]([a-z0-9_]+)['"]/g; + for (const m of mcpSource.matchAll(re)) names.add(m[1]); + return [...names].sort(); +} + +function main(): void { + const root = repoRoot(); + const mcpPath = path.join(root, 'packages/cli/src/cli/agent-relay-mcp.ts'); + const skillPath = path.join(root, '.claude/skills/using-agent-relay/SKILL.md'); + + if (!fs.existsSync(mcpPath) || !fs.existsSync(skillPath)) { + console.error(`Cannot find sources:\n ${mcpPath}\n ${skillPath}`); + process.exit(2); + } + + const registered = registeredToolNames(fs.readFileSync(mcpPath, 'utf8')); + const referenced = extractToolRefs(fs.readFileSync(skillPath, 'utf8')); + const result = checkToolNames(registered, referenced); + + console.log(`Registered tools (${registered.length}): ${registered.join(', ')}`); + console.log(`Referenced in skill (${result.referenced.length} unique)\n`); + + if (result.ok) { + console.log('✓ All onboarding tool names map to a registered tool.'); + process.exit(0); + } + + console.error(`✗ ${result.mismatches.length} onboarding tool name(s) an agent CANNOT call:\n`); + for (const m of result.mismatches) console.error(` ${m.raw} — ${m.reason}`); + console.error( + '\nAgents told to call these will silently fail to send. Reconcile ' + + `${path.relative(root, skillPath)} with the server's registered tools.` + ); + process.exit(1); +} + +main(); diff --git a/tests/integration/broker/evals/tsconfig.json b/tests/integration/broker/evals/tsconfig.json new file mode 100644 index 000000000..7b73511f9 --- /dev/null +++ b/tests/integration/broker/evals/tsconfig.json @@ -0,0 +1,30 @@ +{ + "comment": "Eval-suite build. Compiles only evals/ + the broker test utils it reuses, avoiding the sibling broker test files that are mid-migration against the narrowed SDK. Outputs to ../dist so `node dist/evals/runner.js` resolves ../utils.", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": false, + "sourceMap": true, + "rootDir": "..", + "outDir": "../dist", + "types": ["node"], + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "baseUrl": "../../../..", + "paths": { + "@agent-relay/sdk": ["packages/sdk/dist/index.d.ts"], + "@agent-relay/sdk/*": ["packages/sdk/dist/*"], + "@agent-relay/harness-driver": ["packages/harness-driver/dist/index.d.ts"], + "@agent-relay/harness-driver/*": ["packages/harness-driver/dist/*"], + "@agent-relay/config": ["packages/config/dist/index.d.ts"], + "@agent-relay/config/*": ["packages/config/dist/*"], + "@agent-relay/utils": ["packages/utils/dist/index.d.ts"], + "@agent-relay/utils/*": ["packages/utils/dist/*"] + } + }, + "include": ["**/*.ts", "../utils/**/*.ts"], + "exclude": ["dist", "../dist", "**/*.unit.test.ts"] +} diff --git a/tests/integration/broker/evals/types.ts b/tests/integration/broker/evals/types.ts new file mode 100644 index 000000000..a0b838d03 --- /dev/null +++ b/tests/integration/broker/evals/types.ts @@ -0,0 +1,155 @@ +/** + * Type definitions for the agent messaging eval harness. + * + * The eval suite spawns real agent CLIs, drives them through coordination + * scenarios, and scores — purely from broker events — whether agents actually + * used the messaging tools (MCP/CLI) versus emitting plain-text "phantom" + * messages. + */ +import type { BrokerHarness } from '../utils/broker-harness.js'; + +/** Context handed to each scenario's `run` function. */ +export interface ScenarioContext { + /** A started broker harness. The scenario owns its agents but not the harness lifecycle. */ + harness: BrokerHarness; + /** The CLI/harness under test (e.g. "claude", "codex"). */ + cli: string; + /** Unique suffix for isolating agent/channel names across runs. */ + suffix: string; + /** Sleep helper. */ + sleep: (ms: number) => Promise; +} + +/** + * A single phantom message: forward-looking intent to communicate expressed in + * plain text that was never backed by an actual `relay_inbound` send. + */ +export interface Phantom { + /** The agent that expressed the intent. */ + agent: string; + /** The matched verb (e.g. "tell", "post", "send"). */ + verb: string; + /** The parsed target, if the regex captured one (e.g. "Lead"). */ + target?: string; + /** A short snippet of surrounding text for debugging. */ + snippet: string; +} + +/** An agent under test in a scenario, with the task prompt it was given. */ +export interface AgentInfo { + name: string; + cli: string; + /** Optional role label (e.g. "relay hop A"). */ + role?: string; + /** The task prompt the agent was spawned with. */ + prompt: string; +} + +/** One message in a scenario's conversation, derived from a relay_inbound event. */ +export interface TranscriptEntry { + from: string; + target: string; + body: string; + /** True if `from` is one of the scenario's agents under test (a real send). */ + fromAgent: boolean; + threadId?: string; +} + +/** Raw, scenario-specific signal counts derived from the event stream. */ +export interface ScenarioResult { + id: string; + title: string; + /** Overall pass/fail for this scenario. */ + pass: boolean; + /** Agents under test and the prompts they were given. */ + agents: AgentInfo[]; + /** The full message transcript (stimulus + agent sends), in order. */ + transcript: TranscriptEntry[]; + /** Number of messages the agent(s) actually sent (relay_inbound). */ + sent: number; + /** Number of sends the scenario expected. */ + expected: number; + /** Phantom messages detected (intent without a backing send). */ + phantoms: Phantom[]; + /** Total forward-looking intents detected (satisfied + phantom). */ + totalIntents: number; + /** Protocol adherence score in [0,1], or null if not applicable. */ + protocolAdherence: number | null; + /** Replies that targeted the wrong channel / a DM when a channel was expected. */ + wrongChannelReplies: number; + /** True if no delivery_dropped / acl_denied events occurred. */ + deliveryOk: boolean; + /** Coarse event counts for the report. */ + events: { + relayInbound: number; + dropped: number; + aclDenied: number; + }; + /** Optional human-readable notes (e.g. partial-chain detail). */ + notes?: string; +} + +/** + * Eval tiers: + * - `smoke`: leading prompts that name the exact tool. A plumbing canary — proves + * the broker→MCP→agent→scoring path works; not a measure of protocol retention. + * - `realistic`: natural-language prompts where messaging is incidental to real + * work and the protocol must come from the injected onboarding (skill + broker + * hints) — what production agents actually get. This is the real benchmark. + */ +export type EvalTier = 'smoke' | 'realistic'; + +/** A scenario the runner can execute against a harness. */ +export interface EvalScenario { + id: string; + title: string; + tier: EvalTier; + /** Channels the broker should subscribe to for this scenario. */ + channels: string[]; + /** If set, only run for these harnesses. */ + harnessFilter?: string[]; + /** Overall test timeout in ms. */ + timeoutMs: number; + /** + * Orchestrate the scenario end-to-end: spawn agents, inject the stimulus, + * wait for responses, and score the captured events into a ScenarioResult. + */ + run: (ctx: ScenarioContext) => Promise; +} + +/** Aggregated metrics for one harness across all scenarios. */ +export interface MetricSet { + messageSentRate: number; + phantomRate: number; + phantomCount: number; + protocolAdherence: number; + deliverySuccessRate: number; + wrongChannelReplies: number; + scenariosPassed: number; + scenariosTotal: number; +} + +/** A full report for one harness run. */ +export interface EvalReport { + schemaVersion: number; + startedAt: string; + durationMs: number; + harness: string; + gitSha: string; + env: { + realCli: boolean; + repeat: number; + }; + metrics: MetricSet; + scenarios: ScenarioResult[]; +} + +/** The matrix roll-up across harnesses. */ +export interface MatrixReport { + schemaVersion: number; + startedAt: string; + gitSha: string; + harnesses: Record; +} + +export const SCHEMA_VERSION = 1; diff --git a/tests/integration/broker/tsconfig.json b/tests/integration/broker/tsconfig.json index 2c99c8fa4..9ce733e53 100644 --- a/tests/integration/broker/tsconfig.json +++ b/tests/integration/broker/tsconfig.json @@ -22,5 +22,5 @@ } }, "include": ["**/*.ts"], - "exclude": ["dist"] + "exclude": ["dist", "**/*.unit.test.ts"] } diff --git a/tests/integration/broker/utils/assert-helpers.ts b/tests/integration/broker/utils/assert-helpers.ts index 3c9db8f88..79d25fd25 100644 --- a/tests/integration/broker/utils/assert-helpers.ts +++ b/tests/integration/broker/utils/assert-helpers.ts @@ -7,7 +7,7 @@ */ import assert from 'node:assert/strict'; -import type { BrokerEvent, ListAgent } from '@agent-relay/sdk'; +import type { BrokerEvent, ListAgent } from '@agent-relay/harness-driver'; import type { BrokerHarness } from './broker-harness.js'; // ── Delivery assertions ────────────────────────────────────────────────────── diff --git a/tests/integration/broker/utils/broker-harness.ts b/tests/integration/broker/utils/broker-harness.ts index 1ca18ea5a..93f0bfb3b 100644 --- a/tests/integration/broker/utils/broker-harness.ts +++ b/tests/integration/broker/utils/broker-harness.ts @@ -15,9 +15,8 @@ import { type ListAgent, type SendMessageInput, type BrokerEvent, - AgentRelay, - RelayCast, -} from '@agent-relay/sdk'; +} from '@agent-relay/harness-driver'; +import { RelayCast } from '@relaycast/sdk'; // ── Dynamic API key provisioning ───────────────────────────────────────────── @@ -75,9 +74,7 @@ export interface EventWaiter { // ── Harness ────────────────────────────────────────────────────────────────── export class BrokerHarness { - /** High-level facade — use for spawning agents, sending messages. */ - relay!: AgentRelay; - /** Low-level client — use for protocol-level tests. */ + /** Low-level driver client — spawns the broker and drives agents. */ client!: HarnessDriverClient; private readonly opts: Required; @@ -134,17 +131,6 @@ export class BrokerHarness { } }); - // Create a high-level facade sharing the same binary/options - this.relay = new AgentRelay({ - binaryPath: this.opts.binaryPath, - binaryArgs: this.opts.binaryArgs, - brokerName: this.opts.brokerName, - channels: this.opts.channels, - cwd: this.opts.cwd, - requestTimeoutMs: this.opts.requestTimeoutMs, - env: this.opts.env, - }); - this.started = true; } @@ -158,14 +144,7 @@ export class BrokerHarness { this.unsubEvent = undefined; this.eventListeners = []; - // Shut down the facade first (it has its own client) - try { - await this.relay.shutdown(); - } catch { - // Ignore — may already be down - } - - // Shut down the low-level client + // Shut down the broker process. try { await this.client.shutdown(); } catch { diff --git a/vitest.config.ts b/vitest.config.ts index 295c5e33a..71c79d41c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -60,6 +60,7 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: [ 'tests/fixtures/**/*.test.ts', + 'tests/integration/broker/evals/**/*.unit.test.ts', 'packages/**/src/**/*.test.ts', 'packages/**/src/**/*.test.tsx', 'packages/**/tests/**/*.test.ts',