Files
ruvnet__ruflo/scripts/probe-nested-spawn-depth.mjs
rUv f5a180423d feat(agents): ADR-147 nested subagent (depth=5) infrastructure + P2 stage 1 (#2336)
* 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>
2026-06-09 12:15:03 -04:00

165 lines
6.8 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
// 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` ... (13 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);