Files
ruvnet__ruflo/v3/@claude-flow/cli/scripts/bench-rvagent.mjs
rUv 47a7825b0e feat(rvagent): #ADR-129 — full rvagent integration (4 phases) (#2123)
* 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>
2026-05-24 14:32:43 -04:00

313 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.');