Skip to content

[examples]feat: add Claude Code blackbox training recipe#78

Open
zhaizhiqiangA wants to merge 11 commits into
verl-project:mainfrom
zhaizhiqiangA:blackbox_cc_recipe
Open

[examples]feat: add Claude Code blackbox training recipe#78
zhaizhiqiangA wants to merge 11 commits into
verl-project:mainfrom
zhaizhiqiangA:blackbox_cc_recipe

Conversation

@zhaizhiqiangA

Copy link
Copy Markdown
Collaborator

What does this PR do?

This PR adds a Claude Code blackbox SWE-bench recipe under examples/blackbox_recipes/claude_code/.

Claude Code runs inside the remote SWE-bench sandbox through a sidecar tool image, talks to the rollout model through Uni-Agent's gateway Anthropic Messages endpoint, and is evaluated in the same sandbox through the existing reward-spec path. The PR also adds standalone inference/debug utilities and gateway adapter coverage needed to support Claude Code style Anthropic requests.

Related work:

Checklist Before Starting

Test

  • Unit tests:
NO_PROXY='*' no_proxy='*' HTTP_PROXY='' HTTPS_PROXY='' http_proxy='' https_proxy='' \
python -m pytest \
  tests/uni_agent/gateway/adapters/test_anthropic_adapter.py \
  tests/uni_agent/gateway/adapters/test_openai_adapter.py \
  tests/uni_agent/gateway/test_message_codec_tool_dispatch.py \
  tests/uni_agent/gateway/test_debug_launcher.py \
  tests/uni_agent/gateway/test_gateway_actor_on_cpu.py \
  tests/uni_agent/framework/test_generate_sequences_on_cpu.py

Result: 84 passed, 4 warnings.

  • Manual standalone inference smoke:
    • Ran one SWE-Bench Verified OpenYuanRong sample with Qwen3.5-9B, vLLM, gateway, Claude Code sidecar runner, and reward worker.
    • generate_sequences summary: num_input_prompts=1 num_success_sessions=1 num_failed_sessions=0 num_success_outputs=1
    • INFERENCE_RESULT_JSON={"resolved": 0, "total": 1, "mean_score": 0.0, "per_sample_scores": [0.0]}
    • Gateway trajectory dump recorded 23 prepare/generation rounds with stable Claude Code tool schema.

Full RL training was not run in CI because it requires multi-GPU Ray resources, AKernel credentials, remote sandbox images, and a pushed Claude Code tool image.

API and Usage Example

No breaking public API changes. This adds a new example recipe and gateway/debug entrypoints.

bash examples/blackbox_recipes/claude_code/build_tool.sh \
  --registry swr.cn-east-3.myhuaweicloud.com/openyuanrong

AKERNEL_SERVER_ADDRESS="<host:port>" \
AKERNEL_TOKEN="<token>" \
CLAUDE_CODE_TOOL_IMAGE="<registry>/claude-code-tool:latest" \
MODEL_PATH="~/models/Qwen3.5-9B" \
bash examples/blackbox_recipes/claude_code/run_infer.sh

AKERNEL_SERVER_ADDRESS="<host:port>" \
AKERNEL_TOKEN="<token>" \
CLAUDE_CODE_TOOL_IMAGE="<registry>/claude-code-tool:latest" \
MODEL_PATH="~/models/Qwen3.5-9B" \
bash examples/blackbox_recipes/claude_code/run_train.sh

Design & Code Changes

  • Adds claude_code_runner.py, which creates a remote sandbox, mounts the Claude Code sidecar image at /opt/claude-code, rewrites the gateway URL to the sandbox tunnel, runs claude -p, evaluates reward in the same sandbox, and posts reward_info.
  • Adds a self-contained Claude Code recipe: dataset wrapper, reward utility, Dockerfile, build script, Megatron V1 training config, run_train.sh, and standalone parallel_infer.py / run_infer.sh.
  • Adds shared sandbox_client.py for OpenYuanRong sandbox creation, sidecar mounting, command execution, cleanup, and gateway tunnel URL handling.
  • Extends gateway support with OpenAI/Anthropic adapters, Anthropic Messages conversion, tool-call round trips, session-scoped debug launcher, and tests.
  • Keeps Claude Code runtime dependencies in the sidecar image, so the SWE-bench base sandbox does not need Node/npm installed.

Checklist Before Submitting

  • Read the Contribute Guide
  • Run pre-commit install && pre-commit run --all-files --show-diff-on-failure --color=always
  • Add or update docs/examples for user-facing changes
  • Add tests or explain why tests are not practical
  • Confirm the PR title matches the required format
  • Confirm the placeholder text in this template has been replaced with real content

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a self-contained Claude Code in-sandbox execution recipe, featuring a dedicated Dockerfile, build scripts, runner, parallel inference, and configurations. It also adds a standalone gateway debug launcher and extends the gateway with an Anthropic Messages adapter to support both OpenAI and Anthropic protocols. The review feedback highlights several critical improvements: fixing a missing urlparse import in anthropic.py, gracefully handling null values for tool_choice in both adapters, resolving potential issues with missing ports and lost query/fragment parameters in sandbox_client.py, wrapping synchronous I/O-bound calls in asyncio.to_thread to prevent blocking the event loop, and avoiding unbound variable crashes in build_tool.sh.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +9 to +10
import secrets
from collections.abc import AsyncIterator

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The urlparse function is used on line 343 but is not imported in this file. This will cause a NameError at runtime. Please import urlparse from urllib.parse.

Suggested change
import secrets
from collections.abc import AsyncIterator
import secrets
from urllib.parse import urlparse
from collections.abc import AsyncIterator

Comment on lines +184 to +196
tool_choice_payload = payload.get("tool_choice", "auto")
if isinstance(tool_choice_payload, str):
tool_choice = tool_choice_payload.lower()
if tool_choice not in {"auto", "none"}:
raise MalformedRequestError(
f'tool_choice="{tool_choice_payload}" is not supported (only "auto" / "none" are supported)'
)
elif isinstance(tool_choice_payload, dict):
raise MalformedRequestError(
'tool_choice with a specific function is not supported (only "auto" / "none" are supported)'
)
else:
raise MalformedRequestError("tool_choice must be a string or object")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If tool_choice is explicitly set to null in the JSON payload, tool_choice_payload will be None. This will trigger the else block and raise a MalformedRequestError. We should handle None gracefully by defaulting it to 'auto'.

Suggested change
tool_choice_payload = payload.get("tool_choice", "auto")
if isinstance(tool_choice_payload, str):
tool_choice = tool_choice_payload.lower()
if tool_choice not in {"auto", "none"}:
raise MalformedRequestError(
f'tool_choice="{tool_choice_payload}" is not supported (only "auto" / "none" are supported)'
)
elif isinstance(tool_choice_payload, dict):
raise MalformedRequestError(
'tool_choice with a specific function is not supported (only "auto" / "none" are supported)'
)
else:
raise MalformedRequestError("tool_choice must be a string or object")
tool_choice_payload = payload.get("tool_choice", "auto")
if tool_choice_payload is None:
tool_choice = "auto"
elif isinstance(tool_choice_payload, str):
tool_choice = tool_choice_payload.lower()
if tool_choice not in {"auto", "none"}:
raise MalformedRequestError(
f'tool_choice="{tool_choice_payload}" is not supported (only "auto" / "none" are supported)'
)
elif isinstance(tool_choice_payload, dict):
raise MalformedRequestError(
'tool_choice with a specific function is not supported (only "auto" / "none" are supported)'
)
else:
raise MalformedRequestError("tool_choice must be a string or object")

Comment on lines +502 to +517
def _apply_tool_choice(payload: dict[str, Any], tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
tool_choice = payload.get("tool_choice", "auto")
if isinstance(tool_choice, str):
if tool_choice == "auto":
return tools
if tool_choice == "none":
return None
raise MalformedRequestError(f"Unsupported tool_choice: {tool_choice}")
if isinstance(tool_choice, dict):
choice_type = tool_choice.get("type")
if choice_type == "auto":
return tools
if choice_type == "none":
return None
raise MalformedRequestError(f"Unsupported tool_choice type: {choice_type}")
raise MalformedRequestError("tool_choice must be a string or object")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If tool_choice is explicitly set to null in the JSON payload, tool_choice will be None. This will trigger the else block and raise a MalformedRequestError. We should handle None gracefully by returning tools.

Suggested change
def _apply_tool_choice(payload: dict[str, Any], tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
tool_choice = payload.get("tool_choice", "auto")
if isinstance(tool_choice, str):
if tool_choice == "auto":
return tools
if tool_choice == "none":
return None
raise MalformedRequestError(f"Unsupported tool_choice: {tool_choice}")
if isinstance(tool_choice, dict):
choice_type = tool_choice.get("type")
if choice_type == "auto":
return tools
if choice_type == "none":
return None
raise MalformedRequestError(f"Unsupported tool_choice type: {choice_type}")
raise MalformedRequestError("tool_choice must be a string or object")
def _apply_tool_choice(payload: dict[str, Any], tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
tool_choice = payload.get("tool_choice", "auto")
if tool_choice is None:
return tools
if isinstance(tool_choice, str):
if tool_choice == "auto":
return tools
if tool_choice == "none":
return None
raise MalformedRequestError(f"Unsupported tool_choice: {tool_choice}")
if isinstance(tool_choice, dict):
choice_type = tool_choice.get("type")
if choice_type == "auto":
return tools
if choice_type == "none":
return None
raise MalformedRequestError(f"Unsupported tool_choice type: {choice_type}")
raise MalformedRequestError("tool_choice must be a string or object")

Comment on lines +56 to +62
def extract_upstream(gateway_url: str) -> str:
"""Extract host:port from a gateway URL for upstream tunnel config.

Example: "http://8.92.9.155:40169/sessions/abc/v1" -> "8.92.9.155:40169"
"""
parsed = urlparse(gateway_url)
return f"{parsed.hostname}:{parsed.port}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the gateway_url does not explicitly specify a port (e.g., standard port 80/443 or a domain name behind a reverse proxy), parsed.port will be None, resulting in an invalid upstream address like example.com:None. We should handle None ports gracefully by defaulting to standard ports based on the scheme.

def extract_upstream(gateway_url: str) -> str:
    """Extract host:port from a gateway URL for upstream tunnel config.

    Example: "http://8.92.9.155:40169/sessions/abc/v1" -> "8.92.9.155:40169"
    """
    parsed = urlparse(gateway_url)
    if parsed.port is None:
        port = 80 if parsed.scheme == "http" else 443
        return f"{parsed.hostname}:{port}"
    return f"{parsed.hostname}:{parsed.port}"

Comment on lines +65 to +81
def rewrite_gateway_url(
gateway_url: str,
proxy_port: int = DEFAULT_PROXY_PORT,
*,
strip_v1: bool = False,
) -> str:
"""Rewrite gateway URL to use the sandbox-internal tunnel.

Replaces host:port with 127.0.0.1:<proxy_port>, keeps path intact.

Example:
"http://8.92.9.155:40169/sessions/abc/v1"
-> "http://127.0.0.1:8766/sessions/abc/v1"
"""
parsed = urlparse(gateway_url)
path = parsed.path.removesuffix("/v1") if strip_v1 else parsed.path
return f"http://127.0.0.1:{proxy_port}{path}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the gateway_url contains query parameters or fragments (e.g., for authentication or session metadata), they are completely lost during the rewrite. We should preserve the query and fragment parts of the URL.

Suggested change
def rewrite_gateway_url(
gateway_url: str,
proxy_port: int = DEFAULT_PROXY_PORT,
*,
strip_v1: bool = False,
) -> str:
"""Rewrite gateway URL to use the sandbox-internal tunnel.
Replaces host:port with 127.0.0.1:<proxy_port>, keeps path intact.
Example:
"http://8.92.9.155:40169/sessions/abc/v1"
-> "http://127.0.0.1:8766/sessions/abc/v1"
"""
parsed = urlparse(gateway_url)
path = parsed.path.removesuffix("/v1") if strip_v1 else parsed.path
return f"http://127.0.0.1:{proxy_port}{path}"
def rewrite_gateway_url(
gateway_url: str,
proxy_port: int = DEFAULT_PROXY_PORT,
*,
strip_v1: bool = False,
) -> str:
"""Rewrite gateway URL to use the sandbox-internal tunnel.
Replaces host:port with 127.0.0.1:<proxy_port>, keeps path intact.
Example:
"http://8.92.9.155:40169/sessions/abc/v1"
-> "http://127.0.0.1:8766/sessions/abc/v1"
"""
parsed = urlparse(gateway_url)
path = parsed.path.removesuffix("/v1") if strip_v1 else parsed.path
query = f"?{parsed.query}" if parsed.query else ""
fragment = f"#{parsed.fragment}" if parsed.fragment else ""
return f"http://127.0.0.1:{proxy_port}{path}{query}{fragment}"

Comment on lines +197 to +209
async def cleanup(self) -> None:
"""Kill the sandbox if still running."""
if self._sandbox is not None:
sandbox_id = getattr(self._sandbox, "sandbox_id", "?")
try:
if self._sandbox.is_running():
await asyncio.to_thread(self._sandbox.kill)
logger.info("sandbox %s killed", sandbox_id)
else:
logger.info("sandbox %s already stopped", sandbox_id)
except Exception as e:
logger.warning("Failed to kill sandbox %s: %s", sandbox_id, e)
self._sandbox = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The self._sandbox.is_running() call is synchronous and likely performs network I/O to check the sandbox status. Calling it directly inside an async def blocks the asyncio event loop. We should wrap it in asyncio.to_thread to keep the event loop non-blocking.

Suggested change
async def cleanup(self) -> None:
"""Kill the sandbox if still running."""
if self._sandbox is not None:
sandbox_id = getattr(self._sandbox, "sandbox_id", "?")
try:
if self._sandbox.is_running():
await asyncio.to_thread(self._sandbox.kill)
logger.info("sandbox %s killed", sandbox_id)
else:
logger.info("sandbox %s already stopped", sandbox_id)
except Exception as e:
logger.warning("Failed to kill sandbox %s: %s", sandbox_id, e)
self._sandbox = None
async def cleanup(self) -> None:
"""Kill the sandbox if still running."""
if self._sandbox is not None:
sandbox_id = getattr(self._sandbox, "sandbox_id", "?")
try:
is_running = await asyncio.to_thread(self._sandbox.is_running)
if is_running:
await asyncio.to_thread(self._sandbox.kill)
logger.info("sandbox %s killed", sandbox_id)
else:
logger.info("sandbox %s already stopped", sandbox_id)
except Exception as e:
logger.warning("Failed to kill sandbox %s: %s", sandbox_id, e)
self._sandbox = None

Comment on lines 150 to 167
async def _default_vision_info_extractor(
self,
messages: list[dict[str, Any]],
*,
image_patch_size: int,
**_extra: Any,
) -> tuple[list[Any] | None, list[Any] | None]:
# Keep the dataset dependency lazy so custom extractors do not pay for
# RLHFDataset imports unless they actually use the default path.
from verl.utils.dataset.rl_dataset import RLHFDataset
# Lazy import so callers without multi-modal needs do not load
# qwen_vl_utils. ``_extra`` absorbs ``vision_info_extractor_kwargs`` that
# ``extract_multi_modal_data`` forwards for custom extractors; the
# default path needs nothing beyond ``messages`` and patch size.
from qwen_vl_utils import process_vision_info

return await RLHFDataset.process_vision_info(
return process_vision_info(
messages,
image_patch_size=image_patch_size,
config=self._vision_info_extractor_kwargs.get("config"),
return_video_metadata=True,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

process_vision_info is a synchronous function that performs network/disk I/O (e.g., downloading images from URLs). Calling it directly inside an async def blocks the asyncio event loop. We should wrap it in asyncio.to_thread to keep the event loop non-blocking.

    async def _default_vision_info_extractor(
        self,
        messages: list[dict[str, Any]],
        *,
        image_patch_size: int,
        **_extra: Any,
    ) -> tuple[list[Any] | None, list[Any] | None]:
        # Lazy import so callers without multi-modal needs do not load
        # qwen_vl_utils. ``_extra`` absorbs ``vision_info_extractor_kwargs`` that
        # ``extract_multi_modal_data`` forwards for custom extractors; the
        # default path needs nothing beyond ``messages`` and patch size.
        import asyncio
        from qwen_vl_utils import process_vision_info

        return await asyncio.to_thread(
            process_vision_info,
            messages,
            image_patch_size=image_patch_size,
            return_video_metadata=True,
        )

Comment on lines +25 to +32
while [[ $# -gt 0 ]]; do
case "$1" in
--registry) REGISTRY="$2"; shift 2 ;;
--npm-registry) NPM_REGISTRY="$2"; shift 2 ;;
--tool-version) TOOL_VERSION="$2"; shift 2 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since set -u is active, referencing $2 when parsing the last argument without a value (e.g., --registry with no value) will raise an unbound variable error and crash the script. We should use ${2:-} to safely default to an empty string.

Suggested change
while [[ $# -gt 0 ]]; do
case "$1" in
--registry) REGISTRY="$2"; shift 2 ;;
--npm-registry) NPM_REGISTRY="$2"; shift 2 ;;
--tool-version) TOOL_VERSION="$2"; shift 2 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
while [[ $# -gt 0 ]]; do
case "$1" in
--registry) REGISTRY="${2:-}"; shift 2 ;;
--npm-registry) NPM_REGISTRY="${2:-}"; shift 2 ;;
--tool-version) TOOL_VERSION="${2:-}"; shift 2 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants