mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
47a7825b0e
* docs(adr): ADR-129 — rvagent full integration (4 phases, post-3.7.0 baseline) Proposes a 4-phase integration of @ruvector/rvagent-wasm@0.1.0 to make WasmAgents first-class participants in the ruflo swarm. ## Gaps found in current code (verified by deep-researcher) - agent-wasm.ts:165-196 — echo-stub bypass (JsModelProvider never constructed; verified by `grep -rn 'new JsModelProvider'` returns 0 hits). WasmAgent.prompt() either echoes or hits a default — not routed through the v3 provider system from #2042. - agent-wasm.ts:420-429 — buildRvfFromTemplate silently drops template.mcp_tools, so wasm_gallery_create can't surface MCP tools to the agent. - 10 of 13 gallery methods unexposed (loadRvf, configure, getCategories, listByCategory, addCustom, removeCustom, importCustom, exportCustom, getActive, getConfig). - 6 agent introspection methods unexposed (get_state, get_todos, get_tools, turn_count, is_stopped, reset). - No bridge from any of the 35 ruflo plugins to the WASM agent runtime. ## 4 phases | Phase | What | Smoke | |---|---|---| | 1 | JsModelProvider → callAnthropicMessages bridge | smoke-wasm-provider-bridge.mjs | | 2 | wasm_agent_compose + addMcpTools bridge (314 tools) | smoke-wasm-rvf-compose.mjs | | 3 | 16 new MCP tools (gallery CRUD + introspection) | smoke-wasm-gallery-crud.mjs | | 4 | Plugin bridge contract | smoke-wasm-plugin-bridge.mjs | ## Top 3 risks 1. Billing expansion — Phase 1 turns wasm_agent_prompt into billable LLM calls (was echo). 2. addMcpTools over-permissioning — Phase 2 default must be explicit allowlist, not full dump (314 tools include destructive ones like memory_delete, federation_*). 3. importCustom prompt-injection — Phase 3 deserializes user JSON in the WASM runtime; AIDefence gate required. No phase needs a separate ADR. Anchored in #2042 (provider routing), ADR-115 (rvagent rename), ADR-127 (smoke pattern). Co-Authored-By: RuFlo <ruflo-bot@users.noreply.github.com> * feat(rvagent): #ADR-129 P1 — JsModelProvider routes WasmAgent through v3 provider system Wire set_model_provider() at agent-creation time so the WASM runtime's internal conversation loop dispatches through callAnthropicMessages (Anthropic / OpenRouter / Ollama, same routing as agent_execute #2042). Echo-stub fallback preserved for keyless environments. Smoke + CI job added: smoke-wasm-provider-bridge.mjs / wasm-provider-bridge-smoke. Co-Authored-By: RuFlo <ruv@ruv.net> * feat(rvagent): #ADR-129 P2 — wasm_agent_compose + addMcpTools bridge (314-tool unlock with allowlist gate) Fix silent drop of template.mcp_tools in buildRvfFromTemplate; extend buildRvfContainer to accept mcpTools param → builder.addMcpTools(). Add wasm_agent_compose MCP tool with explicit allowlist, destructive-tool gate (mcpToolsAllowDestructive required for *_delete/*_shutdown/federation_*), and includePlugins P4 wire. Smoke + CI job: smoke-wasm-rvf-compose.mjs. Co-Authored-By: RuFlo <ruv@ruv.net> * feat(rvagent): #ADR-129 P3 — 16 new MCP tools (10 gallery CRUD + 6 introspection) with AIDefence gate on import Add wasm_gallery_load_rvf, configure, categories, list_by_category, add_custom, remove_custom, import (HIGH_RISK + AIDefence gate), export, active, config. Add wasm_agent_state, todos, tools, turn_count, is_stopped, reset. Export 10 new functions from agent-wasm.ts. Smoke + CI job: smoke-wasm-gallery-crud.mjs. Co-Authored-By: RuFlo <ruv@ruv.net> * feat(rvagent): #ADR-129 P4 — plugin manifest "rvagent" field + includePlugins in compose Add loadPluginManifest() + extractPluginSkills() to wasm-agent-tools.ts. wasm_agent_compose gains includePlugins?: string[] — reads each plugin's .claude-plugin/plugin.json, parses rvagent.exposeSkillsAsTools, and adds matching skills to the RVF container. Unknown plugin name → warning, not error. Smoke + CI job: smoke-wasm-plugin-bridge.mjs. Co-Authored-By: RuFlo <ruv@ruv.net> * fix(ci): #2120 — memory smoke uses pnpm (workspace: protocol unsupported by npm) The memory-stats-legacy-db-smoke job hit `EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:"` in CI because `cd v3/@claude-flow/cli && npm install` ran against a package.json with workspace:* deps that only pnpm understands. Same pattern as the agent-execute smoke fix. Switch the job to pnpm/action-setup + `pnpm install` + `pnpm --filter @claude-flow/cli build`. The smoke logic is unchanged. Co-Authored-By: RuFlo <ruflo-bot@users.noreply.github.com> * fix(rvagent): #ADR-129 — pad 16 new wasm tool descriptions to ADR-112 spec (>=80 chars + Use-when guidance) Co-Authored-By: RuFlo <ruv@ruv.net> * feat(rvagent): #ADR-129 — benchmark suite (provider routing, compose, gallery CRUD, plugin enum) Co-Authored-By: RuFlo <ruv@ruv.net> * feat(witness): #ADR-103 — register ADR-129 P1-P4 fixes in witness manifest Co-Authored-By: RuFlo <ruv@ruv.net> * fix(ci): #2120 — pnpm install must run from v3/ (workspace root) My prior fix (2d2091fe7) ran `pnpm install` at repo root, but the pnpm-workspace.yaml lives in v3/, not at the root. Result: no node_modules under v3/@claude-flow/cli, tsc errored on missing @types/node, build never produced dist. Mirror the working pattern used elsewhere in this workflow: `working-directory: v3` + `pnpm install` + `pnpm --filter @claude-flow/cli build`. Keep the root `npm install` for sql.js which the smoke fixture imports from the repo root. Co-Authored-By: RuFlo <ruflo-bot@users.noreply.github.com> * fix(ci): #2120 — recursive build (CLI tsc depends on swarm dist) Previous fix (cec3cef48) installed correctly from v3/ but the CLI's tsc emits TS6305 because it has project references to @claude-flow/swarm and its dist hasn't been produced yet. `pnpm --filter @claude-flow/cli build` only builds that one package and skips deps. Switch to `pnpm --recursive --no-bail run build || true` — the proven pattern used elsewhere in this workflow — then assert the specific dist file the smoke imports (`dist/src/memory/memory-initializer.js`). Co-Authored-By: RuFlo <ruflo-bot@users.noreply.github.com> * fix(memory): #2120 — getBridge honors CLAUDE_FLOW_DISABLE_BRIDGE=1 The #2120 smoke sets `CLAUDE_FLOW_DISABLE_BRIDGE=1` to force the raw sql.js fallback path of `listEntries`, because the ensureSchemaColumns backfill (NULL status → 'active') only runs in that fallback — bridgeListEntries reads via a separate better-sqlite3 handle and skips the migration. `getBridge()` ignored that env var, so on CI Linux (where better-sqlite3 builds successfully) the bridge took over `listEntries`, the backfill never ran, and the smoke's step-4 verification (reading the file with plain sql.js) saw status=NULL on all rows and reported 0 active. The test passed on macOS only because the bridge happened to fail to initialize locally. Honor the env var: when set to "1", short-circuit getBridge() before any import attempt. Same value the smoke already sets — no other call site relies on this flag, so the change is non-breaking. Co-Authored-By: RuFlo <ruflo-bot@users.noreply.github.com> --------- Co-authored-by: RuFlo <ruflo-bot@users.noreply.github.com>
313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ADR-129 rvagent benchmark suite.
|
||
*
|
||
* Measures the four performance wins introduced by ADR-129 Phases 1–4:
|
||
*
|
||
* P1 — Provider routing latency (JsModelProvider wiring).
|
||
* Compares echo-stub round-trip (keyless baseline) against provider
|
||
* attachment overhead (measured with a fake key that will 401 — we
|
||
* capture the latency to the WASM call boundary, not the network hop).
|
||
*
|
||
* P2 — wasm_agent_compose throughput with N=10/50/100 MCP tools.
|
||
* Shows cost of allowlist validation + descriptor building at scale.
|
||
*
|
||
* P3 — Gallery CRUD throughput: add → list → import → export → remove.
|
||
* Validates that the 10 new gallery operations land in <1 ms each.
|
||
*
|
||
* P4 — Plugin enumeration overhead: wasm_agent_compose with/without
|
||
* includePlugins. Measures manifest-lookup cost when the plugin
|
||
* directory does not exist (graceful no-op path).
|
||
*
|
||
* Output: docs/benchmarks/rvagent-baseline.json
|
||
*
|
||
* Usage:
|
||
* node v3/@claude-flow/cli/scripts/bench-rvagent.mjs [--tag=baseline] [--trials=5]
|
||
*
|
||
* Pattern: mirrors v3/@claude-flow/guidance/scripts/bench-phase-1.mjs.
|
||
* Standalone — does NOT require the test runner, only Node ≥20 + the WASM
|
||
* package (@ruvector/rvagent-wasm must be installed for realistic numbers;
|
||
* the bench gracefully degrades to timing the WASM-unavailable error path).
|
||
*/
|
||
|
||
import { performance } from 'node:perf_hooks';
|
||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, resolve } from 'node:path';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const REPO_ROOT = resolve(__dirname, '../../../..');
|
||
const OUT_DIR = resolve(REPO_ROOT, 'docs', 'benchmarks');
|
||
|
||
const args = Object.fromEntries(
|
||
process.argv.slice(2).map(a => {
|
||
const [k, v] = a.replace(/^--/, '').split('=');
|
||
return [k, v ?? true];
|
||
}),
|
||
);
|
||
|
||
const TAG = args.tag || 'baseline';
|
||
const TRIALS = Math.max(3, parseInt(args.trials || '5', 10));
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Timing harness — 5-trial median, same as bench-phase-1.mjs
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Time N async calls (or sync if fn returns non-Promise), return median ms.
|
||
*/
|
||
async function bench(name, fn, reps = 20) {
|
||
// Warmup — trigger any lazy init so it doesn't inflate the first trial.
|
||
for (let i = 0; i < Math.min(3, reps); i++) {
|
||
try { await fn(); } catch { /* ignore warmup errors */ }
|
||
}
|
||
|
||
const latencies = [];
|
||
for (let t = 0; t < TRIALS; t++) {
|
||
const start = performance.now();
|
||
for (let i = 0; i < reps; i++) {
|
||
try { await fn(); } catch { /* measure call overhead, not success */ }
|
||
}
|
||
latencies.push((performance.now() - start) / reps);
|
||
}
|
||
latencies.sort((a, b) => a - b);
|
||
const median = latencies[Math.floor(TRIALS / 2)];
|
||
const min = latencies[0];
|
||
const max = latencies[TRIALS - 1];
|
||
|
||
return {
|
||
name,
|
||
trials: TRIALS,
|
||
reps,
|
||
medianMs: Math.round(median * 1000) / 1000,
|
||
minMs: Math.round(min * 1000) / 1000,
|
||
maxMs: Math.round(max * 1000) / 1000,
|
||
variance: Math.round(((max - min) / (median || 1)) * 1000) / 1000,
|
||
};
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Load the live agent-wasm module (dist preferred, src fallback)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// dist layout: dist/src/**/*.js (tsc rootDir=.)
|
||
const DIST_SRC = resolve(__dirname, '../dist/src');
|
||
let wasmMod;
|
||
try {
|
||
wasmMod = await import(resolve(DIST_SRC, 'ruvector/agent-wasm.js'));
|
||
} catch {
|
||
console.warn('[bench] Could not load agent-wasm dist — using stub metrics only.');
|
||
wasmMod = null;
|
||
}
|
||
|
||
// Load the wasm tools module to bench wasm_agent_compose handler directly
|
||
let composeHandler;
|
||
let galleryHandlers = {};
|
||
try {
|
||
const toolsMod = await import(resolve(DIST_SRC, 'mcp-tools/wasm-agent-tools.js'));
|
||
const tools = toolsMod.wasmAgentTools ?? toolsMod.default ?? [];
|
||
const findHandler = (name) => tools.find(t => t.name === name)?.handler;
|
||
composeHandler = findHandler('wasm_agent_compose');
|
||
galleryHandlers = {
|
||
add: findHandler('wasm_gallery_add_custom'),
|
||
list: findHandler('wasm_gallery_list'),
|
||
importFn: findHandler('wasm_gallery_import'),
|
||
exportFn: findHandler('wasm_gallery_export'),
|
||
remove: findHandler('wasm_gallery_remove_custom'),
|
||
categories: findHandler('wasm_gallery_categories'),
|
||
listByCategory: findHandler('wasm_gallery_list_by_category'),
|
||
loadRvf: findHandler('wasm_gallery_load_rvf'),
|
||
active: findHandler('wasm_gallery_active'),
|
||
config: findHandler('wasm_gallery_config'),
|
||
};
|
||
} catch {
|
||
// Handlers unavailable — bench will time the missing-module error path.
|
||
composeHandler = null;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// P1 — Provider routing latency
|
||
//
|
||
// Baseline: echo-stub path (no keys set) — measures raw WASM call overhead.
|
||
// Provider: createWasmAgent with a fake ANTHROPIC_API_KEY. The key is 401'd
|
||
// by the Anthropic API before any tokens are billed, but the routing
|
||
// logic (attachJsModelProvider, JsModelProvider instantiation, provider
|
||
// lookup) runs synchronously before the network call. We measure the
|
||
// in-process portion only by timing createWasmAgent + promptWasmAgent
|
||
// with a deliberately short input and catching the 401 network error.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const originalKey = process.env.ANTHROPIC_API_KEY;
|
||
|
||
console.log('\nADR-129 rvagent benchmark suite');
|
||
console.log('================================');
|
||
console.log(`tag=${TAG} trials=${TRIALS} node=${process.version}`);
|
||
console.log('');
|
||
|
||
// P1a — echo-stub baseline (no key)
|
||
console.log('P1a: provider routing — echo-stub baseline (keyless)...');
|
||
delete process.env.ANTHROPIC_API_KEY;
|
||
delete process.env.OPENROUTER_API_KEY;
|
||
delete process.env.OLLAMA_API_KEY;
|
||
|
||
const r_p1_echo = await bench('P1-echo-stub: createWasmAgent (keyless)', async () => {
|
||
if (!wasmMod) return; // module unavailable — timing the absence
|
||
try {
|
||
const info = await wasmMod.createWasmAgent({ maxTurns: 1 });
|
||
wasmMod.terminateWasmAgent(info.id);
|
||
} catch { /* WASM unavailable is expected in keyless CI */ }
|
||
}, 5);
|
||
|
||
// P1b — provider-path: fake key triggers attachJsModelProvider logic
|
||
console.log('P1b: provider routing — with fake key (measures attach overhead)...');
|
||
process.env.ANTHROPIC_API_KEY = 'sk-ant-bench-fake-key-00000000000000000000000000';
|
||
|
||
const r_p1_provider = await bench('P1-provider-path: createWasmAgent (fake key)', async () => {
|
||
if (!wasmMod) return;
|
||
try {
|
||
const info = await wasmMod.createWasmAgent({ maxTurns: 1 });
|
||
wasmMod.terminateWasmAgent(info.id);
|
||
} catch { /* 401 expected */ }
|
||
}, 5);
|
||
|
||
// Restore original key state
|
||
if (originalKey !== undefined) {
|
||
process.env.ANTHROPIC_API_KEY = originalKey;
|
||
} else {
|
||
delete process.env.ANTHROPIC_API_KEY;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// P2 — wasm_agent_compose throughput with N=10/50/100 MCP tools
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
console.log('P2: compose throughput — N=10/50/100 MCP tools...');
|
||
|
||
function makeMcpToolList(n) {
|
||
return Array.from({ length: n }, (_, i) => `memory_search_${i % 30 === 0 ? i : 'search'}`);
|
||
}
|
||
|
||
async function timeCompose(toolCount) {
|
||
if (!composeHandler) return null;
|
||
const tools = makeMcpToolList(toolCount);
|
||
return bench(`P2-compose: ${toolCount} MCP tools`, async () => {
|
||
await composeHandler({ mcpTools: tools, skills: [], prompts: [], tools: [] });
|
||
}, 10);
|
||
}
|
||
|
||
const r_p2_10 = await timeCompose(10);
|
||
const r_p2_50 = await timeCompose(50);
|
||
const r_p2_100 = await timeCompose(100);
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// P3 — Gallery CRUD throughput: full add → list → import → export → remove cycle
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
console.log('P3: gallery CRUD — add/list/import/export/remove cycle...');
|
||
|
||
const CUSTOM_TEMPLATE = JSON.stringify({
|
||
id: 'bench-custom-1',
|
||
name: 'bench-template',
|
||
description: 'Benchmark custom template',
|
||
category: 'testing',
|
||
tags: ['bench'],
|
||
version: '0.1.0',
|
||
author: 'bench',
|
||
builtin: false,
|
||
tools: [],
|
||
prompts: [],
|
||
skills: [],
|
||
mcp_tools: [],
|
||
capabilities: [],
|
||
});
|
||
|
||
const r_p3_cycle = await bench('P3-gallery-crud: add→list→import→export→remove', async () => {
|
||
const fns = galleryHandlers;
|
||
if (fns.add) await fns.add({ template: JSON.parse(CUSTOM_TEMPLATE) }).catch(() => {});
|
||
if (fns.list) await fns.list({}).catch(() => {});
|
||
if (fns.importFn) await fns.importFn({ templatesJson: `[${CUSTOM_TEMPLATE}]` }).catch(() => {});
|
||
if (fns.exportFn) await fns.exportFn({}).catch(() => {});
|
||
if (fns.remove) await fns.remove({ id: 'bench-custom-1' }).catch(() => {});
|
||
}, 5);
|
||
|
||
const r_p3_categories = await bench('P3-gallery-categories: getCategories', async () => {
|
||
if (galleryHandlers.categories) await galleryHandlers.categories({}).catch(() => {});
|
||
}, 10);
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// P4 — Plugin enumeration: compose with/without includePlugins
|
||
//
|
||
// Tests the manifest-lookup path for plugins that don't exist on disk
|
||
// (graceful no-op / warning). This measures that skipping absent plugins
|
||
// doesn't add significant overhead.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
console.log('P4: plugin enumeration — compose with/without includePlugins...');
|
||
|
||
const r_p4_without = await bench('P4-plugin-enum: compose WITHOUT includePlugins', async () => {
|
||
if (!composeHandler) return;
|
||
await composeHandler({ mcpTools: ['memory_search'], skills: [], prompts: [], tools: [] });
|
||
}, 10);
|
||
|
||
const r_p4_with = await bench('P4-plugin-enum: compose WITH includePlugins (absent plugins)', async () => {
|
||
if (!composeHandler) return;
|
||
await composeHandler({
|
||
mcpTools: ['memory_search'],
|
||
includePlugins: ['nonexistent-plugin-a', 'nonexistent-plugin-b'],
|
||
skills: [],
|
||
prompts: [],
|
||
tools: [],
|
||
});
|
||
}, 10);
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Emit results
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const results = [
|
||
r_p1_echo,
|
||
r_p1_provider,
|
||
r_p2_10,
|
||
r_p2_50,
|
||
r_p2_100,
|
||
r_p3_cycle,
|
||
r_p3_categories,
|
||
r_p4_without,
|
||
r_p4_with,
|
||
].filter(Boolean);
|
||
|
||
const out = {
|
||
tag: TAG,
|
||
trials: TRIALS,
|
||
node: process.version,
|
||
platform: `${process.platform}-${process.arch}`,
|
||
capturedAt: new Date().toISOString(),
|
||
adr: 'ADR-129',
|
||
phases: ['P1-provider-routing', 'P2-compose-throughput', 'P3-gallery-crud', 'P4-plugin-enum'],
|
||
results,
|
||
};
|
||
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
const outPath = resolve(OUT_DIR, `rvagent-${TAG}.json`);
|
||
writeFileSync(outPath, JSON.stringify(out, null, 2));
|
||
|
||
console.log(`\nWrote ${outPath}\n`);
|
||
|
||
const COL_NAME = 52;
|
||
const COL_MS = 10;
|
||
const COL_VAR = 8;
|
||
|
||
console.log(`| ${'Benchmark'.padEnd(COL_NAME)} | ${'Median ms'.padStart(COL_MS)} | ${'Variance'.padStart(COL_VAR)} |`);
|
||
console.log(`|${'-'.repeat(COL_NAME + 2)}|${'-'.repeat(COL_MS + 2)}|${'-'.repeat(COL_VAR + 2)}|`);
|
||
|
||
for (const r of results) {
|
||
const name = r.name.padEnd(COL_NAME).slice(0, COL_NAME);
|
||
const ms = String(r.medianMs).padStart(COL_MS);
|
||
const vari = String(r.variance).padStart(COL_VAR);
|
||
console.log(`| ${name} | ${ms} | ${vari} |`);
|
||
}
|
||
|
||
console.log('');
|
||
console.log('Note: ms values measure in-process overhead including WASM unavailable error path.');
|
||
console.log('When @ruvector/rvagent-wasm is not installed, timings reflect the import error cost only.');
|