SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
SkillDev toolsA skill for dev tools by wandb.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the SPDX-FileCopyrightText: 2026 CoreWeave, Inc. skill
What this skill tells your AI
The instructions your AI receives, as published by wandb/senpai in .agents/skills/rlm/SKILL.md and read by ahel’s review.
SPDX-License-Identifier: Apache-2.0
SPDX-PackageName: skills
name: rlm description: > Recursive Language Model (RLM) helper for writing parallel async LLM pipelines. Use this skill whenever the task involves processing many items with an LLM in parallel: fan-out extraction, batch classification, bulk annotation, parallel summarisation, or any "map each item → LLM call → aggregate results" workflow. Triggers for: "process all X in parallel", "use RLM", "fan out LLM calls", "batch LLM", "parallel Codex calls", "process hundreds of PRs / files / rows with Codex", "RLM pattern", "recursive language model", "async LLM", "sub-LLM". Also use proactively whenever a script needs to call the Anthropic API in a loop over a list of items — replace the loop with this pattern instead.
RLM (Recursive Language Model) Skill
Based on: arXiv:2512.24601 — Zhang, Kraska & Khattab (MIT, 2025)
Reference implementation: tools/update_wandb_run_metadata.py — extracts W&B run IDs from 1,678 PR bodies using 30 parallel sub-LLM calls.
Core concept
Instead of one LLM call over a huge context, the root (this script/agent) splits the input into atomic items and fans out to parallel sub-LLMs — one call per item. Each sub-LLM operates on a small, focused context and returns structured JSON. The root aggregates the results.
Root orchestrator (this script)
├── item_0 → sub-LLM call → result_0
├── item_1 → sub-LLM call → result_1 (all fired concurrently,
├── item_2 → sub-LLM call → result_2 capped by semaphore)
│ ...
└── item_N → sub-LLM call → result_N
The four strategies from the paper — use the one that fits:
| Strategy | When to use | Sub-LLM prompt shape |
|---|---|---|
| Peek | Inspect a sample before committing to a strategy | "Here are 3 rows — what fields exist?" |
| Grep | Narrow a large corpus to relevant items first | "Does this chunk contain X? Answer yes/no" |
| Partition + Map | Process every item independently | "Extract Y from this item. Return JSON." |
| Summarise | Compress each chunk before a second aggregation pass | "Summarise the key facts in 3 sentences." |
For most senpai tasks (PR analysis, run tagging, metric extraction) Partition + Map is the right choice.
Boilerplate template
Copy this and fill in the three marked sections.
# SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
# SPDX-License-Identifier: Apache-2.0
"""<One-line description of what this script does.>
RLM pattern: root fans out to up to LLM_CONCURRENCY parallel sub-LLM calls,
one per item. Each sub-LLM returns structured JSON.
Usage: uv run tools/<script_name>.py
"""
import asyncio
import json
import os
from pathlib import Path
from anthropic import AsyncAnthropic
from dotenv import load_dotenv
from tqdm.asyncio import tqdm as atqdm
# ---------------------------------------------------------------------------
# Config — adjust as needed
# ---------------------------------------------------------------------------
MODEL = "Codex-sonnet-4-6" # sub-LLM model
LLM_CONCURRENCY = 30 # max parallel API calls (safe limit for Sonnet)
load_dotenv(Path(__file__).parent.parent / ".env")
# ---------------------------------------------------------------------------
# ① SUB-LLM PROMPT — fill this in
# ---------------------------------------------------------------------------
PROMPT_TEMPLATE = """\
<Task description for the sub-LLM. Be explicit about the output format.>
Item:
{item_text}
Respond ONLY with valid JSON, no other text:
{{"field1": "...", "field2": [...]}}
If nothing found: {{"field1": null, "field2": []}}"""
# ---------------------------------------------------------------------------
# ② SUB-LLM CALL — one per item
# ---------------------------------------------------------------------------
async def process_item(
item: dict, # dict with at minimum an "id" key
client: AsyncAnthropic,
sem: asyncio.Semaphore,
) -> dict:
"""Call the sub-LLM for a single item. Returns item dict merged with result."""
# ③ EXTRACT the text to pass to the LLM from your item dict
item_text = item.get("body") or item.get("text") or str(item)
if not item_text.strip():
return {**item, "llm_result": None}
async with sem:
response = await client.messages.create(
model=MODEL,
max_tokens=512, # increase for longer responses
messages=[{
"role": "user",
"content": PROMPT_TEMPLATE.format(item_text=item_text[:12_000]),
}],
)
parsed = json.loads(response.content[0].text.strip())
return {**item, "llm_result": parsed}
# ---------------------------------------------------------------------------
# ROOT ORCHESTRATOR — fan-out + aggregate
# ---------------------------------------------------------------------------
async def run_rlm(items: list[dict]) -> list[dict]:
"""Fan out sub-LLM calls over all items, return results in completion order."""
client = AsyncAnthropic()
sem = asyncio.Semaphore(LLM_CONCURRENCY)
tasks = [process_item(item, client, sem) for item in items]
return await atqdm.gather(*tasks, desc=f"RLM ({MODEL})", total=len(tasks))
# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
def main() -> None:
# ③ LOAD YOUR ITEMS — replace with your actual data source
items = [
{"id": 1, "body": "first item text ..."},
{"id": 2, "body": "second item text ..."},
]
print(f"Processing {len(items)} items with RLM...")
results = asyncio.run(run_rlm(items))
# ③ AGGREGATE — replace with your actual post-processing
for r in results:
if r.get("llm_result"):
print(r["id"], r["llm_result"])
if __name__ == "__main__":
main()
Rate limits and concurrency
| Model | Safe LLM_CONCURRENCY | Notes |
|---|---|---|
Codex-sonnet-4-6 | 30 | Default — good balance of speed and cost |
Codex-haiku-4-5 | 50 | Use for cheap, high-volume sub-calls |
Codex-opus-4-8 | 5 | Heavy model; use only for root-level synthesis |
Use a second semaphore for downstream rate-limited APIs (e.g. W&B, GitHub):
WANDB_CONCURRENCY = 10 # W&B API is stricter than Anthropic
async def update_downstream(item: dict, api, sem: asyncio.Semaphore) -> bool:
async with sem:
try:
return await asyncio.to_thread(_sync_api_call, item)
except Exception as e:
print(f"\n [skip] {item['id']}: {e}", file=sys.stderr)
return False
async def run_updates(items: list[dict], api) -> int:
sem = asyncio.Semaphore(WANDB_CONCURRENCY)
tasks = [update_downstream(item, api, sem) for item in items]
results = await atqdm.gather(*tasks, desc="Updating downstream", total=len(tasks))
return sum(results)
Prompt engineering for sub-LLMs
Sub-LLM prompts should be:
- Extractive, not generative — "extract X" not "describe X". Constrain the search space.
- JSON-only output — always end with
Respond ONLY with valid JSON. Avoids markdown fences. - Schema explicit in the prompt — show the exact keys and types expected.
- Null/empty case handled — always include the empty-result example so the model doesn't hallucinate.
- Item context in the prompt header — include
PR #NNN: titleor similar so the model can orient itself.
Validation pattern — always validate LLM output before use:
def validate_run_ids(raw: list) -> list[str]:
"""Example: validate 8-char W&B run IDs."""
return [
r for r in raw
if isinstance(r, str)
and len(r) == 8
and r.isalnum()
and any(c.isdigit() for c in r)
]
Two-pass pattern: regex first, LLM second
For extraction tasks where a regex can find most cases, use it as a cheap first pass and only invoke LLM where needed. This cuts cost by ~80% on typical PR corpora:
def regex_pass(items: list[dict]) -> dict[int, list[str]]:
return {item["id"]: RUN_ID_RE.findall(item.get("body", "")) for item in items}
async def llm_pass(items: list[dict]) -> dict[int, list[str]]:
results = await run_rlm(items)
return {r["id"]: r["llm_result"].get("run_ids", []) for r in results}
def merge(regex: dict, llm: dict, ids: list[int]) -> dict[int, list[str]]:
"""Prefer LLM result; fall back to regex."""
return {i: (llm.get(i) or regex.get(i, [])) for i in ids}
Multi-role classification
When a single item has multiple extracted entities that need different labels (e.g. one PR has a "final" run and several "pre-merge" runs), ask the sub-LLM to classify each entity:
PROMPT_TEMPLATE = """\
Extract all W&B run IDs from this PR body and classify each one.
Roles:
- "final": the best/featured run reported in the Results section (at most one)
- "pre-merge": earlier or preliminary runs mentioned in the body
PR #{pr_number}: {pr_title}
{pr_body}
Respond ONLY with valid JSON:
{{"runs": [{{"run_id": "abc12345", "role": "final"}}, {{"run_id": "xyz98765", "role": "pre-merge"}}]}}
If no run IDs: {{"runs": []}}"""
Error handling philosophy
- Don't wrap sub-LLM calls in try/except — let
asyncio.gatherpropagate LLM errors. A malformed JSON response from one call failing the whole batch surfaces the bug immediately. - Do wrap downstream side-effects (W&B updates, DB writes, file writes) in a narrow try/except — these fail for external reasons (404, rate limit) unrelated to the logic.
- json.loads will raise on bad JSON — this is intentional. If you need resilience, add a Peek pass first to validate the model reliably returns JSON before the main run.
Worked example in this repo
tools/update_wandb_run_metadata.py — full production example:
- Input: 1,678 GitHub PRs (closed + merged)
- Partition + Map: 30 parallel
Codex-sonnet-4-6calls extract W&B run IDs + classify role - Downstream action: 10-concurrent W&B config updates via
asyncio.to_thread - Result: 1,255 W&B runs tagged with
gh_pr_branch,gh_pr_status,gh_pr_url,gh_pr_run_stage - Wall time: ~4 min LLM pass + ~2 min W&B pass for 1,678 items
Key excerpt:
async def run_llm_pass(prs: list[dict], client: AsyncAnthropic) -> dict[int, list[dict]]:
sem = asyncio.Semaphore(LLM_CONCURRENCY) # 30
tasks = [extract_runs_llm(pr, client, sem) for pr in prs]
results = await atqdm.gather(*tasks, desc="RLM: extracting run IDs", total=len(tasks))
return {pr["number"]: runs for pr, runs in zip(prs, results)}
When NOT to use RLM
| Situation | Better approach |
|---|---|
| Single item or < 5 items | Direct LLM call, no async needed |
| Items have dependencies (B needs A's output) | Sequential chain, not fan-out |
| Need the LLM to reason across all items together | Single call with all items in context |
| Pure regex/heuristic suffices | Skip LLM entirely |
| Reading a codebase for patterns | Use Codex's built-in Grep/Read tools |
Signals
- GitHub stars
- 34
- Forks
- 5
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
rlm-wandb- Source
- github.com/wandb/senpai