diff --git a/examples/coding_agent_rl/docker_sandbox.py b/examples/coding_agent_rl/docker_sandbox.py new file mode 100644 index 000000000..c36e45405 --- /dev/null +++ b/examples/coding_agent_rl/docker_sandbox.py @@ -0,0 +1,287 @@ +"""Remote Docker sandbox — drop-in replacement for E2BSandbox. + +Connects to a remote Docker daemon over SSH (no TCP port needed). +Implements the same interface as vime.agent.sandbox.Sandbox so +sandbox.py needs zero changes other than the import. + +Usage in sandbox.py — replace: + from vime.agent.sandbox import E2BSandbox, Sandbox +with: + from .docker_sandbox import DockerSandbox as E2BSandbox, Sandbox + +Environment variables: + DOCKER_SANDBOX_HOST SSH target, e.g. root@192.168.13.188 (REQUIRED) + DOCKER_SANDBOX_MEM container memory limit, default 8g + DOCKER_SANDBOX_CPUS container CPU quota, default 4 + DOCKER_CONTAINER_TIMEOUT default exec timeout seconds, default 120 +""" + +from __future__ import annotations + +import asyncio +import io +import logging +import os +import tarfile +import tempfile +from pathlib import Path +from typing import Any + +import docker +import docker.errors + +logger = logging.getLogger(__name__) + +_DOCKER_HOST = os.environ.get("DOCKER_SANDBOX_HOST", "root@192.168.13.188") +_MEM_LIMIT = os.environ.get("DOCKER_SANDBOX_MEM", "8g") +_CPUS = float(os.environ.get("DOCKER_SANDBOX_CPUS", "4")) +_DEFAULT_TIMEOUT = int(os.environ.get("DOCKER_CONTAINER_TIMEOUT", "120")) + +# Module-level shared client — one SSH connection, reused across sandboxes. +# _client: docker.DockerClient | None = None +# _client_lock = asyncio.Lock() + + +def _make_client() -> docker.DockerClient: + """每个调用新建一个 DockerClient,避免多进程/多线程 SSH channel 竞争。""" + return docker.DockerClient( + base_url=f"ssh://{_DOCKER_HOST}", + max_pool_size=1, + ) +# --------------------------------------------------------------------------- +# Minimal Sandbox base (mirrors vime.agent.sandbox.Sandbox interface) +# --------------------------------------------------------------------------- +class Sandbox: + """Abstract interface — matches vime.agent.sandbox.Sandbox.""" + + async def exec( + self, + cmd: str, + *, + user: str = "root", + check: bool = True, + timeout: int = _DEFAULT_TIMEOUT, + env: dict[str, str] | None = None, + ) -> tuple[int, str, str]: + raise NotImplementedError + + async def write_file( + self, + sandbox_path: str, + content_or_host_path: str | bytes | Path, + user: str = "root", + ) -> None: + raise NotImplementedError + + async def read_file(self, sandbox_path: str, user: str = "root") -> str: + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# DockerSandbox +# --------------------------------------------------------------------------- +class DockerSandbox(Sandbox): + def __init__(self, image: str) -> None: + self.image = image + self._container = None + self._client = None # 实例级,不共享 + + async def __aenter__(self) -> "DockerSandbox": + loop = asyncio.get_event_loop() + docker_tarball_dir = os.environ.get("DOCKER_TARBALL_DIR") + def _start(): + self._client = _make_client() # 每个沙箱独立连接 + vime_head_host = os.environ.get("VIME_HEAD_HOST", "") + no_proxy=os.environ.get("no_proxy", f"127.0.0.1,localhost,{vime_head_host}") + NO_PROXY=os.environ.get("NO_PROXY", f"127.0.0.1,localhost,{vime_head_host}") + http_proxy=os.environ.get("http_proxy", "") + https_proxy=os.environ.get("https_proxy", "") + return self._client.containers.run( + self.image, + command="sleep infinity", + detach=True, + mem_limit=_MEM_LIMIT, + nano_cpus=int(_CPUS * 1e9), + network_mode="bridge", + cap_add=["SYS_PTRACE"], + remove=False, + volumes={ + docker_tarball_dir: { + "bind": docker_tarball_dir, + "mode": "rw" + } + }, + environment={ + "http_proxy": http_proxy, + "https_proxy": https_proxy, + "no_proxy": no_proxy, + "NO_PROXY": NO_PROXY, + }, + ) + + self._container = await loop.run_in_executor(None, _start) + await self.exec("mkdir -p /workspace", user="root", check=False) + return self + + async def __aexit__(self, *args): + if self._container is None: + return + loop = asyncio.get_event_loop() + container = self._container + client = self._client + + def _stop(): + try: + container.remove(force=True) + except Exception: + pass + try: + client.close() + except Exception: + pass + + await loop.run_in_executor(None, _stop) + self._container = None + self._client = None + + # ------------------------------------------------------------------ + # exec + # ------------------------------------------------------------------ + async def exec( + self, + cmd: str, + *, + user: str = "root", + check: bool = True, + timeout: int = _DEFAULT_TIMEOUT, + env: dict[str, str] | None = None, + ) -> tuple[int, str, str]: + loop = asyncio.get_event_loop() + + def _run() -> tuple[int, str, str]: + result = self._container.exec_run( + ["bash", "-c", cmd], + user=user, + environment=env or {}, + demux=True, + tty=False, + ) + exit_code = result.exit_code + stdout_b, stderr_b = result.output or (b"", b"") + stdout = (stdout_b or b"").decode("utf-8", errors="replace") + stderr = (stderr_b or b"").decode("utf-8", errors="replace") + return exit_code, stdout, stderr + + try: + exit_code, stdout, stderr = await asyncio.wait_for( + loop.run_in_executor(None, _run), + timeout=timeout, + ) + except asyncio.TimeoutError: + logger.warning("[docker_sandbox] exec timeout (%ds): %s", timeout, cmd[:120]) + if check: + raise + return -1, "", f"timeout after {timeout}s" + + if check and exit_code != 0: + raise RuntimeError( + f"[docker_sandbox] exec failed (exit {exit_code})\n" + f"cmd: {cmd[:200]}\nstderr: {stderr[:500]}" + ) + return exit_code, stdout, stderr + + # ------------------------------------------------------------------ + # write_file + # ------------------------------------------------------------------ + async def write_file( + self, + sandbox_path: str, + content_or_host_path: str | bytes | Path, + user: str = "root", + ) -> None: + loop = asyncio.get_event_loop() + sandbox_path = str(sandbox_path) + + # Resolve content bytes + p_str = str(content_or_host_path) + if isinstance(content_or_host_path, Path): + data = content_or_host_path.read_bytes() + elif isinstance(content_or_host_path, bytes): + data = content_or_host_path + else: + data = p_str.encode("utf-8") + + filename = os.path.basename(sandbox_path) + dirpath = os.path.dirname(sandbox_path) or "/" + + # Ensure parent directory exists + await self.exec(f"mkdir -p {dirpath}", user="root", check=False) + + # Pack into tar and send via put_archive + def _put() -> None: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + info = tarfile.TarInfo(name=filename) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + buf.seek(0) + self._container.put_archive(dirpath, buf) + + await loop.run_in_executor(None, _put) + + # Fix ownership if needed + if user and user != "root": + await self.exec(f"chown {user}:{user} {sandbox_path}", user="root", check=False) + + # ------------------------------------------------------------------ + # read_file + # ------------------------------------------------------------------ + async def read_file(self, sandbox_path: str, user: str = "root") -> str: + loop = asyncio.get_event_loop() + + def _get() -> str: + bits, _ = self._container.get_archive(sandbox_path) + buf = io.BytesIO(b"".join(bits)) + with tarfile.open(fileobj=buf) as tf: + member = tf.getmembers()[0] + f = tf.extractfile(member) + return f.read().decode("utf-8", errors="replace") if f else "" + + return await loop.run_in_executor(None, _get) + + +# --------------------------------------------------------------------------- +# Quick smoke-test (run directly: python docker_sandbox.py) +# --------------------------------------------------------------------------- +async def _smoke_test() -> None: + image = os.environ.get("DOCKER_SANDBOX_TEST_IMAGE", "ubuntu:22.04") + print(f"[smoke] connecting to {_DOCKER_HOST}, image={image}") + async with DockerSandbox(image) as sb: + # exec + ec, out, err = await sb.exec("echo hello && uname -m", user="root") + print(f"[smoke] exec exit={ec} stdout={out.strip()!r}") + assert ec == 0 and "hello" in out + + # write_file (string content) + await sb.write_file("/tmp/test.txt", "hello docker\n", user="root") + + # read_file + content = await sb.read_file("/tmp/test.txt") + print(f"[smoke] read_file: {content!r}") + assert "hello docker" in content + + # write_file (host path) + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + f.write(b"from host\n") + host_path = f.name + await sb.write_file("/tmp/from_host.txt", Path(host_path), user="root") + content2 = await sb.read_file("/tmp/from_host.txt") + print(f"[smoke] host-path write: {content2!r}") + assert "from host" in content2 + + print("[smoke] ALL PASSED") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(_smoke_test()) \ No newline at end of file diff --git a/examples/coding_agent_rl/run_qwen3_30b_a3b_swe.sh b/examples/coding_agent_rl/run_qwen3_30b_a3b_swe.sh new file mode 100644 index 000000000..826cdd61d --- /dev/null +++ b/examples/coding_agent_rl/run_qwen3_30b_a3b_swe.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# End-to-end SWE coding-agent RL on single a3 node. +# +# Run from a long-lived shell / tmux session on the Ray head node; do not wrap +# in a short-lived nohup launcher or Ray child processes get cleaned up with it. + +# for rerun the task +pkill -9 -f '[v]llm serve|VLL[M]::' || true +pkill -9 -f VLLM || true +sleep 3 +ray stop --force || true +pkill -9 ray || true +pkill -9 python || true +sleep 3 +pkill -9 ray || true +pkill -9 python || true +pkill -9 redis || true + +set -ex + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +VIME_DIR="${VIME_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" +source "${VIME_DIR}/scripts/models/qwen3-30B-A3B.sh" + +# ============ context length ============ +MAX_CONTEXT_LEN="${MAX_CONTEXT_LEN:-40960}" +MAX_GEN_LEN="${MAX_GEN_LEN:-32768}" + +# ============ paths — override before launching ============ +HF_CHECKPOINT="${HF_CHECKPOINT:-/home/vllm/weights/Qwen3-30B-A3B}" +REF_MODEL_PATH="${REF_MODEL_PATH:-/home/vllm/weights/Qwen3-30B-A3B_torch_dist_8cards}" +PROMPT_DATA="${PROMPT_DATA:-/home/vllm/c00944022/datasets/swebench_verified/swe_train.jsonl}" + +EXP_TAG="${EXP_TAG:-agent_only}" +STAMP="$(date +%Y%m%d_%H%M%S)" +RUN_ROOT="${RUN_ROOT:-${VIME_DIR}/runs/${EXP_TAG}_${STAMP}}" + +# ============ logging ============ +LOG_DIR="${RUN_ROOT}" +mkdir -p "${LOG_DIR}/rollout_dumps" +LOG_FILE="${LOG_DIR}/run.log" +echo "======================================================================" +echo "Training log: ${LOG_FILE}" +echo "RUN_ROOT=${RUN_ROOT}" +echo "======================================================================" + + +CKPT_ARGS=( + --hf-checkpoint "${HF_CHECKPOINT}" + --load "${HF_CHECKPOINT}" + --ref-load "${HF_CHECKPOINT}" + --megatron-to-hf-mode bridge + # --debug-rollout-only + # --debug-train-only +) + +ROLLOUT_ARGS=( + --custom-generate-function-path examples.coding_agent_rl.generate.generate + --prompt-data "${PROMPT_DATA}" + --input-key prompt + --label-key label + --metadata-key metadata + --num-rollout 100 + --rollout-batch-size 8 + --n-samples-per-prompt 8 + --rollout-max-context-len ${MAX_CONTEXT_LEN} + --rollout-max-response-len ${MAX_GEN_LEN} + --rollout-temperature 1.0 + --rollout-stop-token-ids 151645 151643 + --num-steps-per-rollout 1 + --global-batch-size 64 + --micro-batch-size 1 + # --save-debug-rollout-data "${RUN_ROOT}/rollout_dumps/rollout_{rollout_id}.pt" +) + +PERF_ARGS=( + --tensor-model-parallel-size 4 + --sequence-parallel + --pipeline-model-parallel-size 1 + --context-parallel-size 1 + --expert-model-parallel-size 8 + --expert-tensor-parallel-size 1 + --recompute-granularity full + --recompute-method uniform + --recompute-num-layers 1 + # max-tokens-per-gpu is one CP rank's slice of MAX_CONTEXT_LEN; log-probs are + # chunked along T to avoid OOM on long single trajectories. + --max-tokens-per-gpu 40960 + --log-probs-chunk-size 1024 + --use-dynamic-batch-size +) + +ALGO_ARGS=( + --advantage-estimator grpo + --kl-loss-coef 0.00 + --kl-loss-type low_var_kl + --kl-coef 0.00 + --entropy-coef 0.00 + --eps-clip 1e-4 + --eps-clip-high 2e-4 +) + +OPTIMIZER_ARGS=( + --optimizer adam + --lr 1e-6 + --lr-decay-style constant + --weight-decay 0.1 + --adam-beta1 0.9 + --adam-beta2 0.98 + --optimizer-cpu-offload + --overlap-cpu-optimizer-d2h-h2d + --use-precision-aware-optimizer +) + +VLLM_ARGS=( + --rollout-num-gpus-per-engine 8 + --vllm-gpu-memory-utilization 0.75 + --vllm-tool-call-parser qwen3_coder + --vllm-reasoning-parser qwen3 + # --prefill-num-servers 1 + # --vllm-enforce-eager +) + +MISC_ARGS=( + --attention-dropout 0.0 + --hidden-dropout 0.0 + --accumulate-allreduce-grads-in-fp32 + --attention-softmax-in-fp32 + --attention-backend flash + # --moe-token-dispatcher-type flex + # --moe-enable-deepep + --use-flash-attn + --no-gradient-accumulation-fusion +) + +# Set MASTER_ADDR before the SWE block +export MASTER_ADDR="192.168.13.190" +export VIME_HEAD_HOST="${MASTER_ADDR}" + +# ============ SWE / claude-code rollout knobs ============ +E2B_DUMMY_API_KEY="e2b_0000000000000000000000000000000000000000" +SANDBOX_METADATA_FILE=/dev/null +export E2B_API_KEY="${E2B_DUMMY_API_KEY}" +export SWE_SANDBOX_METADATA_FILE="${SANDBOX_METADATA_FILE}" + +export DOCKER_SANDBOX=1 +export DOCKER_SANDBOX_HOST="root@192.168.13.188" +export DOCKER_TARBALL_DIR="/home/vllm/c00944022/vime-agent/env" +export SWE_HOST_NODE_TARBALL="${DOCKER_TARBALL_DIR}/node-v24.14.0-linux-x64.tar.xz" +export SWE_HOST_CC_TARBALL="${DOCKER_TARBALL_DIR}/anthropic-ai-claude-code-2.1.226.tgz" +export SWE_HOST_CC_TARBALL_DEP="${DOCKER_TARBALL_DIR}/claude-code-linux-x64-2.1.226.tgz" + +# --- per-trajectory time / concurrency budgets --- +export SWE_TIME_BUDGET_SEC="${SWE_TIME_BUDGET_SEC:-1800}" +export SWE_EVAL_TIMEOUT_SEC="${SWE_EVAL_TIMEOUT_SEC:-600}" +export SWE_BOOT_CONCURRENCY="${SWE_BOOT_CONCURRENCY:-6}" + +# --- claude-code CLI extras --- +SETTINGS_JSON='{"permissions":{"defaultMode":"bypassPermissions"},"autoCompactEnabled":true,"autoCompactWindow":80000}' +AGENTS_JSON='{"investigator":{"description":"Searches the repo for relevant files before any edit","prompt":"You are an investigator sub-agent. Use Grep/Read/Glob to find every file relevant to the user task, then return a short bulleted summary. Do NOT edit anything.","tools":["Grep","Read","Glob"]}}' +export SWE_CLAUDE_EXTRA_ARGS="--settings '${SETTINGS_JSON}' --disable-slash-commands --agents '${AGENTS_JSON}' --disallowedTools WebFetch WebSearch" + +# ============ proxy bypass for in-cluster traffic ============ +export no_proxy="127.0.0.1,${MASTER_ADDR},${VIME_HEAD_HOST}" +export NO_PROXY="${no_proxy}" +unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY + +# ============ Ascend env vars ============ +export PYTHONUNBUFFERED=1 +export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 +export RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES=1 +export CUDA_DEVICE_MAX_CONNECTIONS=1 +export HCCL_HOST_SOCKET_PORT_RANGE=60000-60050 +export HCCL_NPU_SOCKET_PORT_RANGE=61000-61050 +export HYDRA_FULL_ERROR=1 +export DISABLE_L2_CACHE=1 +export VLLM_ASCEND_ENABLE_NZ=0 +export VLLM_USE_AOT_COMPILE=0 +export PYTHONPATH="/home/vllm/c00944022/vime-proj/Megatron-Bridge/src:/home/vllm/c00944022/vime-proj/Megatron-LM/:${PYTHONPATH:-}" + + +ray start --head --node-ip-address "${MASTER_ADDR}" \ + --disable-usage-stats --dashboard-host=0.0.0.0 --dashboard-port=8267 --dashboard-agent-listen-port=52367 + +echo "Waiting for Ray cluster to stabilize..." +sleep 30 +ray status + +ray job submit --address="http://${MASTER_ADDR}:8267" \ + -- python3 -u train.py \ + --actor-num-nodes 1 \ + --actor-num-gpus-per-node 8 \ + --rollout-num-gpus 8 \ + "${MODEL_ARGS[@]}" \ + "${CKPT_ARGS[@]}" \ + "${ROLLOUT_ARGS[@]}" \ + "${OPTIMIZER_ARGS[@]}" \ + "${ALGO_ARGS[@]}" \ + "${PERF_ARGS[@]}" \ + "${VLLM_ARGS[@]}" \ + "${MISC_ARGS[@]}" \ + 2>&1 | tee "${LOG_FILE}" + +echo "RUN_ROOT=${RUN_ROOT}" diff --git a/examples/coding_agent_rl/sandbox.py b/examples/coding_agent_rl/sandbox.py index cd9fbe48b..d620ac3fc 100644 --- a/examples/coding_agent_rl/sandbox.py +++ b/examples/coding_agent_rl/sandbox.py @@ -20,7 +20,10 @@ from contextlib import asynccontextmanager from pathlib import Path -from vime.agent.sandbox import E2BSandbox, Sandbox +if os.environ.get("DOCKER_SANDBOX", True): + from .docker_sandbox import DockerSandbox as E2BSandbox, Sandbox +else: + from vime.agent.sandbox import E2BSandbox, Sandbox logger = logging.getLogger(__name__) @@ -42,6 +45,12 @@ "/path/to/anthropic-ai-claude-code.tgz", ) ) +SWE_HOST_CC_TARBALL_DEP = Path( + os.environ.get( + "SWE_HOST_CC_TARBALL_DEP", + "/path/to/claude-code-linux-x64.tgz", + ) +) SWE_BOOT_CONCURRENCY = int(os.environ.get("SWE_BOOT_CONCURRENCY", "16")) SWE_BOOT_RETRIES = int(os.environ.get("SWE_BOOT_RETRIES", "2")) CC_PROMPT = os.environ.get( @@ -109,6 +118,8 @@ async def install_node22(sb: Sandbox, host_tarball: Path) -> None: """Node 22 over the base image (Debian 12 ships 16; cli.js needs >= 20). Decompresses .xz on the host (cached) so sandboxes without xz-utils can still run plain `tar xf`. npm prefix=/usr/local required for sweap-images.""" + tarball_in_container = _tarball_path_in_container(host_tarball) + ''' host_tarball = Path(host_tarball) if host_tarball.suffix == ".xz": plain = Path(tempfile.gettempdir()) / f"coding_agent_rl.{host_tarball.stem}.tar" @@ -119,13 +130,14 @@ async def install_node22(sb: Sandbox, host_tarball: Path) -> None: os.replace(tmp, plain) host_tarball = plain await sb.write_file("/tmp/node22.tar", host_tarball) + ''' await sb.exec( - "set -e && mkdir -p /opt/node22 && " - "tar xf /tmp/node22.tar -C /opt/node22 --strip-components=1 && " - "ln -sf /opt/node22/bin/node /usr/local/bin/node && " - "ln -sf /opt/node22/bin/npm /usr/local/bin/npm && " - "ln -sf /opt/node22/bin/npx /usr/local/bin/npx && " - "hash -r 2>/dev/null || true && node --version && npm --version", + f"set -e && mkdir -p /opt/node22 && " + f"tar xf {tarball_in_container} -C /opt/node22 --strip-components=1 && " + f"ln -sf /opt/node22/bin/node /usr/local/bin/node && " + f"ln -sf /opt/node22/bin/npm /usr/local/bin/npm && " + f"ln -sf /opt/node22/bin/npx /usr/local/bin/npx && " + f"node --version && npm --version", user="root", timeout=180, check=True, @@ -133,19 +145,36 @@ async def install_node22(sb: Sandbox, host_tarball: Path) -> None: async def install_claude_code(sb: Sandbox, host_tarball: Path) -> None: - await sb.write_file("/tmp/claude-code.tgz", host_tarball) + # await sb.write_file("/tmp/claude-code.tgz", host_tarball) + tarball_in_container = _tarball_path_in_container(host_tarball) + tarball_in_container_dep = _tarball_path_in_container(SWE_HOST_CC_TARBALL_DEP) await sb.exec( - "npm install -g --prefix=/usr/local --no-audit --no-fund /tmp/claude-code.tgz " - "&& ls -la /usr/local/bin/claude && /usr/local/bin/claude --version", + f"npm config set strict-ssl false && " + f"npm install -g --prefix=/usr/local --no-audit --no-fund --no-optional {tarball_in_container_dep} {tarball_in_container} " + f"&& claude --version", user="root", timeout=300, check=True, ) +def _tarball_path_in_container(host_tarball: Path) -> str: + """Return the path at which a Docker-mounted tarball is visible. + + The Docker backend mounts ``DOCKER_TARBALL_DIR`` at the same path in the + container. Keeping this lookup in one place also makes the bootstrap + functions usable with a different mount directory in tests or on another + host. The E2B backend still uses ``write_file`` (the block above), so its + path is unaffected by this helper. + """ + mount_dir = os.environ.get("DOCKER_TARBALL_DIR", "/home/vllm/c00944022/vime-agent/env") + return f"{mount_dir.rstrip('/')}/{Path(host_tarball).name}" + + async def ensure_agent_user(sb: Sandbox, workdir: str) -> None: """Create the unprivileged 'agent' user that owns workdir + can git diff. Settings file pre-acks bypass-permissions so claude-code starts headless.""" + await sb.exec(f"mkdir -p {workdir}", user="root", check=True) await sb.exec( f"id agent >/dev/null 2>&1 || useradd -m -s /bin/bash agent && " f"chown -R agent:agent /home/agent {workdir} && " @@ -199,6 +228,18 @@ async def run_claude_code( problem_statement or "", user="agent", ) + # Commit the baseline (base_commit + test_patch + PROBLEM_STATEMENT.md) + # so that the model's subsequent git diff excludes test_patch changes. + # Without this, the eval sandbox applies pre_commands again and then + # tries to re-apply the diff which already contains the test_patch, + # causing git apply to fail. + await sb.exec( + f"cd {workdir} && git -c user.name='cagent' -c user.email='cagent@local' " + f"add -A && " + f"git -c user.name='cagent' -c user.email='cagent@local' " + f"commit -m 'baseline with test patch' --allow-empty", + user="agent", timeout=120, check=False, + ) return await _spawn_claude_code( sb, workdir=workdir, @@ -283,9 +324,14 @@ async def _spawn_claude_code( async def git_diff(sb: Sandbox, workdir: str) -> str: cmd = ( f"cd {workdir} && git add -N . && " - f"git diff -- . ':(exclude)PROBLEM_STATEMENT.md' " + f"git diff -- . " + f"':(exclude)PROBLEM_STATEMENT.md' " f"':(exclude)claude_code_trajectory.jsonl' " - f"':(exclude).cagent_done' ':(exclude).cagent_run.sh'" + f"':(exclude).cagent_done' ':(exclude).cagent_run.sh' " + f"':(exclude)*/tests/*' " + f"':(exclude)*/test_*.py' " + f"':(exclude)*_test.py' " + f"':(exclude)*/testing/*' " ) _, out, _ = await sb.exec(cmd, user="agent", timeout=120) return out