mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
f5a180423d
* docs(adr): add ADR-147 — nested subagent capability integration (depth=5) Captures the integration plan for the nested-subagent capability Boris Cherny announced 2026-06-09 (depth=5 cap). Based on direct inspection of CLI 2.1.169: the plumbing (parentAgentId propagation, hasTaskTool per-spawn gate, Agent/Task tool aliasing, parent_agent_id OTel tag) is in the shipped binary, but the depth=5 cap is not encoded as named symbols and zero ruflo agent definitions declare a tools: field — so spawned children currently inherit hasTaskTool=false. Four-phase rollout: grant Task only to orchestrator-class agents (P1), capture parent_agent_id into AgentDB on post-task (P2), depth-aware pre-task guardrail with cap=4 default behind CLAUDE_FLOW_STRICT_NESTING (P3), rewrite CLAUDE.md queen-coordinator pattern + cross-reference ADR-099/143/144 (P4). Refs ruvnet/ruflo#2335 Co-Authored-By: RuFlo <ruv@ruv.net> * feat(ruflo-agent): add nested-subagent agents + skill (ADR-147 P1) Adds four orchestrator/leaf agent definitions to the ruflo-agent plugin, each with an explicit tools: frontmatter — the missing piece that lets spawned children inherit Claude Code 2.1.169's hasTaskTool gate and actually nest. nested-coordinator — generic deep-delegation orchestrator (has Task) nested-researcher — recursive research orchestrator (has Task) nested-reviewer — find→adversarial-verify reviewer (has Task) nested-leaf — leaf-worker template, deliberately no Task (demonstrates the least-privilege boundary) Plus a paired SKILL.md (skills/nested-subagents/) documenting when to nest vs. flat fan-out, the depth budget (cap=4 default, 5 API), and the required child-summary contract. Implements P1 of ADR-147. Refs ruvnet/ruflo#2335. Co-Authored-By: RuFlo <ruv@ruv.net> * feat(ruflo-agent): add nested-queen — full-ruflo-stack nested orchestrator Companion to nested-coordinator. Where nested-coordinator gives just depth (Claude Code Task tool, depth=5 cap), nested-queen wires the nested spawn tree into ruflo's existing machinery: - swarm_init + hive-mind_spawn (queen role, raft consensus) - hive-mind_consensus / coordination_consensus for branch decisions (replaces inline averaging in the diverse-lens reviewer pattern) - hooks_intelligence_* RETRIEVE -> JUDGE -> DISTILL -> CONSOLIDATE pipeline (full ADR-074..088 alignment), with trajectory tracking per spawn - memory_store / memory_search_unified for tree-shape patterns (HNSW-indexed lookup of prior similar trees before spawning) - claims_claim / claims_handoff / claims_load — ADR-144 AuthScope monotonic reduction enforced at every parent->child hop - aidefence_scan / aidefence_is_safe on outbound prompts and inbound child summaries — ADR-131 / ADR-146 P2 boundary - cost-budget pre-spawn check (refuse early when budget tight) - Five hard constraints made explicit: depth budget, scope monotonicity, AIDefence reject = no consume, pre-spawn budget, mandatory trajectory close Selection guidance: default to nested-coordinator; use nested-queen only when consensus, tree-shape learning, scope enforcement, content gating, or hard cost budget is genuinely required. The overhead is ~10x for a reason. Refs ADR-147, ADR-144, ADR-131, ADR-146, ADR-099, ADR-097. Refs ruvnet/ruflo#2335. Co-Authored-By: RuFlo <ruv@ruv.net> * feat(ruflo-agent): tier-2 specialists — queen-researcher, queen-reviewer, queen-leaf Mirrors the tier-1 specialist lineup (researcher / reviewer / leaf) at tier 2 — each wires its role into ruflo's full machinery: nested-queen-researcher - HNSW pattern-search of prior research trees before spawning - hive-mind raft consensus on which followups to pursue (replaces silent inline ranking — the bias defence) - AIDefence on both outbound prompts (web content quoted in) and inbound child summaries (injected results) - Full trajectory record + EWC-consolidated pattern-store per tree nested-queen-reviewer - Phase 2 verifier vote becomes hive-mind byzantine consensus (tolerates f < N/3 lying verifiers); raft for the diverse-lens variant where verifiers aren't byzantine - AIDefence on diff content forwarded to verifiers + verifier reasoning returned - Pattern-store of review-tree shapes — what catches bugs vs FPs nested-queen-leaf - Still no Task tool (ADR-147 P1 least-privilege boundary) - AIDefence-scans its own inbound prompt; refuses on reject - claims_load confirms inherited AuthScope is still valid - Records hooks_intelligence_trajectory-step on completion (success or failure) — the leaf's contribution to the queen's learning pipeline Together with nested-queen, the tier-2 set now mirrors tier-1 one-for-one. Selection rule remains: default to tier 1; reach for tier 2 only when consensus, content gating, scope enforcement, or tree-shape learning genuinely earns the overhead. Refs ADR-147, ADR-144, ADR-131, ADR-146, ADR-099. Refs ruvnet/ruflo#2335. Co-Authored-By: RuFlo <ruv@ruv.net> * test(adr-147): empirical depth probe + ADR P1 validation results Adds scripts/probe-nested-spawn-depth.mjs — runs claude -p with ruflo-agent:nested-coordinator, drives a self-replicating chain L1->L2->... until refusal or test-limit (level 7), and reports the observed cap. Writes results to docs/probes/. Two runs against CLI 2.1.169 (one with default env, one with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1). Both return: FINAL: level=1 status=NO_AGENT_TOOL Empirical finding: declaring tools: [Task] in an agent's YAML frontmatter is necessary but not sufficient in 2.1.169. The plugin loader honors the field (claude plugin details lists the agent with its declared tool set), but the runtime's parent->child spawn does not propagate Task to the child based on the child's YAML allow-list. No env-var or CLI flag found unlocks it. ADR-147 Validation section updated with the full empirical block: - P1 infrastructure (agents + skill) is shipped and correct - End-to-end verification of the depth=5 cap is NOT possible in 2.1.169 — runtime gate appears to be server-side or held behind a feature rollout not yet user-enableable - P2 + P3 explicitly deferred until the probe returns a positive verdict; both require a working nested spawn to exercise - P4 must NOT claim nested spawning is currently usable - The probe stays in-tree as the regression test — re-running it after future CLI upgrades is the first verification step This is the honest "P1 actually complete" answer: infrastructure landed, empirical limit captured, follow-on phases gated on the runtime activation we cannot force from outside the binary. Refs ruvnet/ruflo#2335 Co-Authored-By: RuFlo <ruv@ruv.net> * docs(adr-147): add Path-2 sweep findings — denylist confirmed Sharpens the empirical block in ADR-147 with results from the four-variant CLI flag sweep: - Control (no flags): L1 has Read,Grep,Glob,Bash - --allowedTools (incl Task,Agent): same - --permission-mode bypassPermissions: same - --agent nested-coordinator (lead is nc): same All four return EXACTLY the same 4-tool list. Our YAML declares 6 tools (Task, Read, Grep, Glob, TodoWrite, Bash). 4 propagate, 2 are stripped. The runtime applies a hardcoded denylist that drops Task and TodoWrite at parent->child spawn time. The strip is consistent across permission modes, lead agent identity, and explicit --allowedTools grants -- so the gate is server-side or hardcoded on specific tool names, not a user-facing toggle. Favorable for P1: the YAML mechanism IS the right opt-in shape. Our agents are declaratively correct. When the denylist for Task lifts (whether by a future build, a server-side rollout, or a discovered opt-out flag), nested spawning activates with zero code changes to ruflo's agents. Refs ruvnet/ruflo#2335 Co-Authored-By: RuFlo <ruv@ruv.net> * feat(hooks): ADR-147 P2 stage 1 — thread parent_agent_id + depth through post-task Wires the nested-subagent spawn-tree lineage through the post-task hook chain so when Claude Code starts populating the `x-claude-code-parent-agent-id` header (or its OTel span tag) on spawned subagents, callers can pass it through and the data lands in the existing feedback storage WITHOUT a schema migration. Path: CLI flags -> MCP tool input schema -> bridgeRecordFeedback -> LearningSystem.recordFeedback + memory entry (JSON.stringify(options) on bridgeStoreEntry preserves the new fields automatically). Changes: - commands/hooks.ts: post-task gains --parent-agent-id and --depth flags - mcp-tools/hooks-tools.ts: hooks_post-task input schema gains parentAgentId (string, validateIdentifier) and depth (integer, 0 <= d <= 32). Both optional; rejection returns typed error and short-circuits before bridge call. - memory/memory-bridge.ts: bridgeRecordFeedback options extended; forwarded to LearningSystem.recordFeedback. JSON.stringify at the bridgeStoreEntry call picks them up for the persisted memory entry automatically. Tests (vitest, 7 cases, all green): - propagation when supplied - omission when not supplied (top-level lead path) - depth=0 boundary (must propagate as 0, not be coerced to undefined) - validation rejects invalid parentAgentId - validation rejects negative depth - validation rejects non-integer depth - validation rejects depth > 32 (defensive bound) Why this is testable today despite the runtime denylist documented in ADR-147 P1: the OTel parent_agent_id tag the binary emits already exists for flat depth-1 spawns. The same code path captures it for a depth-5 chain once the Task-tool denylist lifts upstream — no further code changes needed. DEFERRED to P2 stage 2 (separate PR): - Dedicated parent_agent_id + depth columns on the feedback / trajectories table (currently lands in the JSON metadata blob) - Automatic capture from the active OTel span context (currently caller must pass via CLI/MCP) - Query API to read the spawn tree by agent_id (downstream of the dedicated columns) Refs ruvnet/ruflo#2335 Co-Authored-By: RuFlo <ruv@ruv.net>
165 lines
6.8 KiB
JavaScript
165 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
||
// scripts/probe-nested-spawn-depth.mjs
|
||
//
|
||
// Empirical probe of Claude Code's nested-subagent depth cap (announced 2026-06-09
|
||
// by Boris Cherny: "Capped at depth=5 to start"). Runs a fresh `claude -p` session,
|
||
// spawns ruflo-agent:nested-coordinator at L1, and that coordinator recursively
|
||
// spawns more nested-coordinators (L2, L3, ...) until either (a) some level's
|
||
// Agent-tool call returns an error, or (b) we reach L7 (one past the announced
|
||
// cap) and stop voluntarily.
|
||
//
|
||
// Output:
|
||
// - prints the verbatim chain to stdout
|
||
// - writes results to docs/probes/nested-spawn-depth-<ISO timestamp>.txt
|
||
// - exits 0 on completion (regardless of observed cap); 1 only on infra failure
|
||
//
|
||
// Required state: ruflo-agent plugin cache must contain the nested-* agents
|
||
// (run `claude plugin details ruflo-agent` first — expect Agents (9) listed).
|
||
|
||
import { spawn } from 'node:child_process';
|
||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
const REPO_ROOT = resolve(import.meta.dirname, '..');
|
||
const OUT_DIR = join(REPO_ROOT, 'docs', 'probes');
|
||
const TEST_LIMIT = 7; // stop voluntarily one past the announced cap of 5
|
||
const BUDGET_USD = '3.00';
|
||
|
||
const RECURSIVE_PROCEDURE = `You are participating in an empirical test of Claude Code's nested-subagent depth cap.
|
||
|
||
YOU ARE AT LEVEL N (a number passed to you in this prompt; see "CURRENT LEVEL" below).
|
||
|
||
PROCEDURE — follow exactly, no narration:
|
||
|
||
1. If you do NOT have the Agent tool in your tool list, output ONLY one line:
|
||
"level=N status=NO_AGENT_TOOL"
|
||
and stop.
|
||
|
||
2. If N >= ${TEST_LIMIT}, output ONLY one line:
|
||
"level=N status=TEST_LIMIT_HIT"
|
||
and stop.
|
||
|
||
3. Otherwise, call the Agent tool ONCE with these exact parameters:
|
||
- subagent_type: "nested-coordinator"
|
||
- name: "L<N+1>"
|
||
- description: "Depth probe L<N+1>"
|
||
- prompt: THIS ENTIRE PROCEDURE, but with the line "CURRENT LEVEL: <N>" rewritten as "CURRENT LEVEL: <N+1>"
|
||
Do NOT set isolation or run_in_background.
|
||
|
||
4. When the child returns, output ONE LINE only:
|
||
"level=N spawn=ok child={ <verbatim child output> }"
|
||
or, if the Agent tool itself returned an error:
|
||
"level=N spawn=FAILED error={ <verbatim error message> }"
|
||
|
||
No prose, no markdown, no headers. Exactly one line. The verbatim child output may contain its own
|
||
"level=" lines — that is expected and desired (it's how we measure depth).
|
||
|
||
CURRENT LEVEL: 1`;
|
||
|
||
const ROOT_PROMPT = `Empirical probe: nested-subagent depth cap. Spawn ONE sub-agent and report its result verbatim.
|
||
|
||
Use the Agent tool with EXACTLY these parameters:
|
||
subagent_type: "nested-coordinator"
|
||
name: "L1"
|
||
description: "Depth probe L1"
|
||
prompt: (the procedure shown below — pass it verbatim)
|
||
|
||
When the L1 agent returns, output its result prefixed with "FINAL: " on its own line. No other prose.
|
||
|
||
--- PROCEDURE TO PASS TO L1 ---
|
||
${RECURSIVE_PROCEDURE}
|
||
--- END PROCEDURE ---`;
|
||
|
||
console.log('=== Nested-subagent depth probe ===');
|
||
console.log(`Test limit: ${TEST_LIMIT} (one past announced cap of 5)`);
|
||
console.log(`Budget cap: $${BUDGET_USD}`);
|
||
console.log('Running `claude -p` ... (1–3 minutes typical)\n');
|
||
|
||
const startedAt = new Date();
|
||
const args = [
|
||
'-p',
|
||
'--max-budget-usd', BUDGET_USD,
|
||
'--model', 'claude-haiku-4-5',
|
||
'--output-format', 'text',
|
||
ROOT_PROMPT,
|
||
];
|
||
|
||
const stdoutChunks = [];
|
||
const stderrChunks = [];
|
||
const child = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
child.stdout.on('data', (b) => {
|
||
stdoutChunks.push(b);
|
||
process.stdout.write(b);
|
||
});
|
||
child.stderr.on('data', (b) => stderrChunks.push(b));
|
||
|
||
const exitCode = await new Promise((res) => {
|
||
child.on('close', res);
|
||
child.on('error', () => res(-1));
|
||
});
|
||
|
||
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
||
const stderr = Buffer.concat(stderrChunks).toString('utf-8');
|
||
const finishedAt = new Date();
|
||
|
||
// Parse the chain. Count nested "level=N spawn=ok" → depth+1 successes.
|
||
const okMatches = [...stdout.matchAll(/level=(\d+)\s+spawn=ok/g)].map((m) => Number(m[1]));
|
||
const failMatches = [...stdout.matchAll(/level=(\d+)\s+spawn=FAILED\s+error=\{\s*([^\n}]+)\s*\}/g)];
|
||
const noToolMatches = [...stdout.matchAll(/level=(\d+)\s+status=NO_AGENT_TOOL/g)].map((m) => Number(m[1]));
|
||
const limitHitMatches = [...stdout.matchAll(/level=(\d+)\s+status=TEST_LIMIT_HIT/g)].map((m) => Number(m[1]));
|
||
|
||
const deepestOk = okMatches.length ? Math.max(...okMatches) : null;
|
||
const firstFailure = failMatches.length
|
||
? { level: Number(failMatches[0][1]), error: failMatches[0][2] }
|
||
: null;
|
||
const noTool = noToolMatches.length ? Math.min(...noToolMatches) : null;
|
||
const testLimitHit = limitHitMatches.length ? Math.max(...limitHitMatches) : null;
|
||
|
||
let verdict;
|
||
if (noTool !== null) {
|
||
verdict = `INCONCLUSIVE — Agent tool was missing at level=${noTool}. Likely the tools: [Task] frontmatter is not being honored by this CLI build, or the cache stage didn't include the agent file at that level.`;
|
||
} else if (testLimitHit !== null) {
|
||
verdict = `CAP NOT REACHED — chain ran to test limit (level=${testLimitHit}); the runtime cap is at least ${testLimitHit}. Re-run with a higher TEST_LIMIT to find it.`;
|
||
} else if (firstFailure) {
|
||
// deepest_ok + 1 == first failing spawn. The failing level tried to spawn and failed,
|
||
// so the runtime allowed spawning UP TO firstFailure.level, but not BEYOND.
|
||
verdict = `CAP OBSERVED at depth=${firstFailure.level} (level ${firstFailure.level} could not spawn level ${firstFailure.level + 1}). Error: ${firstFailure.error}`;
|
||
} else if (deepestOk !== null) {
|
||
verdict = `Chain partially completed — deepest successful spawn reported at level=${deepestOk}. No explicit refusal seen; child may have hallucinated success or output was truncated.`;
|
||
} else {
|
||
verdict = `NO RECURSIVE OUTPUT detected. The root spawn likely never returned a structured chain. Inspect raw output below.`;
|
||
}
|
||
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
const stamp = startedAt.toISOString().replace(/[:.]/g, '-');
|
||
const outFile = join(OUT_DIR, `nested-spawn-depth-${stamp}.txt`);
|
||
const report = [
|
||
'=== Nested-subagent depth probe — empirical result ===',
|
||
`started: ${startedAt.toISOString()}`,
|
||
`finished: ${finishedAt.toISOString()}`,
|
||
`duration_ms: ${finishedAt - startedAt}`,
|
||
`exit_code: ${exitCode}`,
|
||
`cli_command: claude ${args.slice(0, -1).join(' ')} <prompt>`,
|
||
'',
|
||
'--- VERDICT ---',
|
||
verdict,
|
||
'',
|
||
`okMatches (levels that successfully spawned): ${JSON.stringify(okMatches)}`,
|
||
`failMatches: ${JSON.stringify(failMatches.map((m) => ({ level: Number(m[1]), error: m[2] })))}`,
|
||
`noToolMatches: ${JSON.stringify(noToolMatches)}`,
|
||
`testLimitHit: ${JSON.stringify(limitHitMatches)}`,
|
||
'',
|
||
'--- RAW STDOUT ---',
|
||
stdout,
|
||
'',
|
||
'--- RAW STDERR ---',
|
||
stderr,
|
||
].join('\n');
|
||
|
||
writeFileSync(outFile, report, 'utf-8');
|
||
|
||
console.log('\n=== VERDICT ===');
|
||
console.log(verdict);
|
||
console.log(`\nFull report: ${outFile}`);
|
||
process.exit(0);
|