Add terminal-rl directory with Terminal RL training scripts and infrastructure

This commit is contained in:
Xuyang Chen
2026-03-10 23:38:55 -04:00
parent 74fc4ed394
commit 86531e9f2f
26 changed files with 5763 additions and 0 deletions
+220
View File
@@ -5,3 +5,223 @@ swe-rl/scripts/logs
swe.pem
__pycache__/
*.pyc
logs
.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
+105
View File
@@ -0,0 +1,105 @@
# Terminal RL
RL training for terminal agents. The agent interacts with Docker-hosted environments and is trained with GRPO (optional PRM).
This workflow has two independent components:
- **Training machine** runs task router + Ray + training scripts, and connects to workers via `WORKER_URLS`
- **Remote workers:** run the pool server and execute tasks (Docker required): [remote/README.md](remote/README.md)
---
## Prerequisites
- **Training machine:** GPU node/cluster with the required training dependencies.
- **Remote workers:** Docker-capable hosts reachable from the training machine (default pool server port **18081**). Setup: [remote/README.md](remote/README.md).
---
## Instructions
### 0. Start remote workers (pool server)
Follow [remote/README.md](remote/README.md) on each worker to start `pool_server` (it should be reachable at e.g. `http://<worker-ip>:18081`).
### 1. Clone the repo
From a directory of your choice:
```bash
git clone https://github.com/Gen-Verse/OpenClaw-RL.git
cd OpenClaw-RL
```
### 2. Prepare dataset (download + convert)
Download a supported dataset under `terminal-rl/dataset/`:
```bash
export DATASET_DIR="terminal-rl/dataset"
python terminal-rl/data_utils/download.py seta_env
```
Convert tasks into training JSONL:
```bash
python terminal-rl/data_utils/convert_task_to_dataset.py \
--tasks_dir terminal-rl/dataset/seta_env
```
The `seta_env` dataset corresponds to the task dataset published in: [camel-ai/seta-env](https://github.com/camel-ai/seta-env/tree/main/Dataset).
### 3. Run training
On the training machine, set the required environment variables:
```bash
# Hugging Face cache / model paths
export HF_HOME="/path/to/huggingface"
export MODEL_CKPT="/path/to/model"
export REF_LOAD="/path/to/reference_model_dir"
export SAVE_CKPT="/path/to/save/checkpoints"
# Dataset + workers
export ROLLOUT_PROMPT_DATA="/path/to/train.jsonl"
export WORKER_URLS="http://worker1:18081,http://worker2:18081"
# Logging
export WANDB_KEY="your-wandb-key"
```
Then run (from repo root):
```bash
bash terminal-rl/terminal_qwen3_8b_rl.sh
```
---
### PRM training (optional)
To enable PRM scoring with the 2-node script, add:
```bash
export PRM_ENABLE=1
export PRM_MODEL_PATH="/path/to/prm-model"
export PRM_M=3
export PRM_STEP_COEF=1.0
export PRM_TEMPERATURE=0.0
export PRM_MAX_NEW_TOKENS=4096
# Optional: use an external PRM endpoint instead of framework-hosted engines
export PRM_SGLANG_URL="http://<prm-router-ip>:<prm-router-port>"
```
Then run:
```bash
bash terminal-rl/terminal_qwen3_8b_prm_rl_2nodes.sh
```
---
## Notes
- `WORKER_URLS` must point to already-running pool servers.
- As an example, one rollout agent implementation in this repo is based on **CAMEL** (see `terminal-rl/agent/camel_agent.py` and [CAMEL](https://github.com/camel-ai/camel)).
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import asyncio
import datetime
import json
import logging
import re
import threading
import time
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
from openai import AsyncStream, Stream
from openai.lib.streaming.chat import (
AsyncChatCompletionStreamManager,
ChatCompletionStreamManager,
)
from openai.types.chat import ChatCompletion
from pydantic import BaseModel
from camel.agents import ChatAgent
from camel.messages import BaseMessage, FunctionCallingMessage, OpenAIMessage
from camel.models import BaseModelBackend
from camel.responses import ChatAgentResponse
from camel.types import ChatCompletionChunk, ModelType, OpenAIBackendRole
from camel.types.agents import ToolCallingRecord
from camel.utils import OpenAITokenCounter
from camel.utils.token_counting import BaseTokenCounter
from inference_client import SGLangTurnClient
from .prompts import get_developer_agent_prompt
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from transformers.tokenization_utils_fast import PreTrainedTokenizerFast
class HFTokenCounter(BaseTokenCounter):
"""Token counter backed by the same HF tokenizer used for generation."""
def __init__(
self, tokenizer: "PreTrainedTokenizerFast", tokens_per_message: int = 3
) -> None:
self.tokenizer = tokenizer
self.tokens_per_message = tokens_per_message
def count_tokens_from_messages(self, messages: List[OpenAIMessage]) -> int:
num_tokens = 0
for message in messages:
num_tokens += self.tokens_per_message
for _, value in message.items():
if not isinstance(value, list):
num_tokens += len(self.tokenizer.encode(str(value)))
continue
for item in value:
if isinstance(item, dict) and item.get("type") == "text":
num_tokens += len(
self.tokenizer.encode(str(item.get("text", "")))
)
else:
num_tokens += len(self.tokenizer.encode(str(item)))
num_tokens += 3
return num_tokens
def encode(self, text: str) -> List[int]:
return self.tokenizer.encode(text)
def decode(self, token_ids: List[int]) -> str:
return self.tokenizer.decode(token_ids)
class CamelAgentBackend(BaseModelBackend):
"""Backend adapter that can reuse the external SGLang turn client."""
def __init__(
self,
model_type: ModelType | str,
*,
sglang_client: SGLangTurnClient | None = None,
model_config_dict: Dict[str, Any] | None = None,
token_counter: BaseTokenCounter | None = None,
) -> None:
super().__init__(
model_type=model_type,
model_config_dict=model_config_dict or {},
api_key=None,
url=None,
token_counter=token_counter,
timeout=30.0,
max_retries=0,
)
self._sglang_client = sglang_client
self._turn_counter = 0
self.cache: dict[str, Any] = {}
@property
def token_counter(self) -> BaseTokenCounter:
if not self._token_counter:
hf_tokenizer = None
if self._sglang_client is not None:
hf_tokenizer = getattr(self._sglang_client, "tokenizer", None)
if hf_tokenizer is not None:
self._token_counter = HFTokenCounter(hf_tokenizer)
else:
self._token_counter = OpenAITokenCounter(ModelType.GPT_4O_MINI)
return self._token_counter
@property
def stream(self) -> bool:
return False
def _run(
self,
messages: list[OpenAIMessage],
response_format: type[BaseModel] | None = None,
tools: list[dict[str, Any]] | None = None,
) -> (
ChatCompletion
| Stream[ChatCompletionChunk]
| ChatCompletionStreamManager[BaseModel]
):
_ = (messages, response_format, tools)
raise RuntimeError("CamelAgentBackend._run is not used by AgentRunner.")
async def _arun(
self,
messages: list[OpenAIMessage],
response_format: type[BaseModel] | None = None,
tools: list[dict[str, Any]] | None = None,
) -> (
ChatCompletion
| AsyncStream[ChatCompletionChunk]
| AsyncChatCompletionStreamManager[BaseModel]
):
_ = response_format
if self._sglang_client is None:
raise RuntimeError("CamelAgentBackend has no SGLang client configured.")
chat_completion, interaction = await self._sglang_client.generate_turn(
messages=messages,
tools=tools,
turn_idx=self._turn_counter,
)
self.cache[chat_completion.id] = interaction
self._turn_counter += 1
return chat_completion
# Adapted from https://github.com/camel-ai/seta/blob/main/training/tbench_areal_workflow/chat_agent_trace.py
class CamelAgent(ChatAgent):
"""ChatAgent extension used by AgentRunner's rollout loop."""
def __init__(
self,
*,
model_type: str,
sglang_client: SGLangTurnClient,
non_think_mode: bool,
max_total_tokens: int,
max_parse_errors: int | None = None,
system: str = "Linux (in Docker)",
machine: str = "x86_64",
is_workforce: bool = False,
current_date: str | None = None,
) -> None:
prompt_date = current_date or str(datetime.date.today())
system_prompt = get_developer_agent_prompt(
current_date=prompt_date,
system=system,
machine=machine,
is_workforce=is_workforce,
non_think_mode=non_think_mode,
)
backend = CamelAgentBackend(model_type=model_type, sglang_client=sglang_client)
super().__init__(
system_message=BaseMessage.make_assistant_message(
role_name="Developer Agent",
content=system_prompt,
),
model=backend,
tools=[],
token_limit=max_total_tokens,
)
super().reset()
self.max_parse_errors = max(1, int(max_parse_errors or 3))
self.parse_error_count = 0
self._tool_call_records: List[Any] = []
self._accumulated_context_tokens = 0
self._step_token_usage = self._create_token_usage_tracker()
self._original_response_format: Optional[Type[BaseModel]] = None
self._used_prompt_formatting: bool = False
def set_max_parse_errors(self, max_parse_errors: int) -> None:
self.max_parse_errors = max(1, int(max_parse_errors))
def start_turn_loop(
self,
input_message: Union[BaseMessage, str],
response_format: Optional[Type[BaseModel]] = None,
) -> None:
self.parse_error_count = 0
self._tool_call_records = []
self._accumulated_context_tokens = 0
self._step_token_usage = self._create_token_usage_tracker()
self._original_response_format = response_format
input_message, _response_format, used_prompt_formatting = (
self._handle_response_format_with_non_strict_tools(
input_message,
response_format,
)
)
self._used_prompt_formatting = used_prompt_formatting
if isinstance(input_message, str):
input_message = BaseMessage.make_user_message(
role_name="User", content=input_message
)
self.update_memory(input_message, OpenAIBackendRole.USER)
async def _wait_if_paused(self) -> None:
if self.pause_event is None or self.pause_event.is_set():
return
if isinstance(self.pause_event, asyncio.Event):
await self.pause_event.wait()
return
if isinstance(self.pause_event, threading.Event):
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self.pause_event.wait)
async def get_turn_context(
self,
) -> tuple[Optional[List[dict[str, Any]]], Optional[ChatAgentResponse]]:
await self._wait_if_paused()
try:
context_messages, num_tokens = self.memory.get_context()
self._accumulated_context_tokens += num_tokens
return context_messages, None
except RuntimeError as exc:
terminated_response = self._step_terminate(
exc.args[1],
self._tool_call_records,
"max_tokens_exceeded",
)
return None, terminated_response
async def consume_completion(
self, chat_completion: Any
) -> tuple[Optional[Any], List[Any], bool, Optional[ChatAgentResponse]]:
model_response = self._handle_batch_response(chat_completion)
self._update_token_usage_tracker(
self._step_token_usage, model_response.usage_dict
)
if self.stop_event and self.stop_event.is_set():
logger.info("Termination triggered by stop_event")
terminated_response = self._step_terminate(
self._accumulated_context_tokens,
self._tool_call_records,
"termination_triggered",
)
return model_response, [], False, terminated_response
if model_response.tool_call_requests:
return model_response, list(model_response.tool_call_requests), False, None
parse_error_record = await self.adetect_tool_calls_parse_error(model_response)
if parse_error_record:
logger.warning(
f"Detected tool call parse error, prompting model to correct."
)
self._tool_call_records.append(parse_error_record)
return model_response, [], True, None
return model_response, [], False, None
async def adetect_tool_calls_parse_error(self, response):
r"""
Asynchronously detect tool calls in the response content using Qwen25Detector.
if the model is Qwen 2.5 or Qwen 3.
if there's tool call tokens detected, but got json parse failure, format the information into a tool call record,
so that the agent can handle the error next step.
add a self.count_parse_error, so that we can limit the number of parse errors we handle in one step. if max reached, just
break the loop.
Args:
response: The model response to check for parse errors
Returns:
Optional[ToolCallingRecord]: A tool calling record with error information if parse error detected, None otherwise
"""
bot_token = "<tool_call>\n"
eot_token = "\n</tool_call>"
# Check if we've reached max parse errors
if self.parse_error_count >= self.max_parse_errors:
logger.warning(
f"Max parse errors ({self.max_parse_errors}) reached, stopping error handling"
)
return None
# Extract content from response
if not response.output_messages:
return None
content = response.output_messages[0].content
if not content or bot_token not in content:
return None
# Find all potential tool call blocks
pattern = rf"{re.escape(bot_token)}(.*?){re.escape(eot_token)}"
matches = re.findall(pattern, content, re.DOTALL)
if not matches:
return None
# Check each match for JSON parse errors
for match_text in matches:
try:
# Try to parse the JSON
json.loads(match_text.strip())
# If successful, no error for this match
continue
except json.JSONDecodeError as e:
# Found a parse error
self.parse_error_count += 1
logger.warning(
f"Detected JSON parse error (count: {self.parse_error_count}/{self.max_parse_errors}): {str(e)}"
)
logger.warning(f"Problematic content: {match_text[:200]}...")
# Create an error tool calling record
error_message = (
f"JSON Parse Error: {str(e)}\n"
f"The tool call format is incorrect. Please ensure:\n"
f"1. The JSON is valid and properly formatted\n"
f"2. All quotes are properly escaped\n"
f"3. The structure matches: {{'name': 'function_name', 'arguments': {{}}}}\n"
f"Problematic content (first 200 chars): {match_text[:200]}..."
)
# Generate a unique error tool call ID
error_tool_call_id = f"error_{uuid.uuid4().hex[:8]}"
# Create the error record
error_record = ToolCallingRecord(
tool_name="json_parse_error",
args={"raw_content": match_text, "error": str(e)},
result=error_message,
tool_call_id=error_tool_call_id,
)
# Record this in memory so the model can see the error
assist_msg = FunctionCallingMessage(
role_name=self.role_name,
role_type=self.role_type,
meta_dict=None,
content="",
func_name="json_parse_error",
args={"raw_content": match_text[:200], "error": str(e)},
tool_call_id=error_tool_call_id,
)
func_msg = FunctionCallingMessage(
role_name=self.role_name,
role_type=self.role_type,
meta_dict=None,
content="",
func_name="json_parse_error",
result=error_message,
tool_call_id=error_tool_call_id,
)
# Use precise timestamps
current_time_ns = time.time_ns()
base_timestamp = current_time_ns / 1_000_000_000
self.update_memory(
assist_msg, OpenAIBackendRole.ASSISTANT, timestamp=base_timestamp
)
self.update_memory(
func_msg,
OpenAIBackendRole.FUNCTION,
timestamp=base_timestamp + 1e-6,
)
return error_record
return None
def record_tool_result(self, tool_call_request: Any, raw_result: Any) -> None:
func_name = tool_call_request.tool_name
args = tool_call_request.args
tool_call_id = tool_call_request.tool_call_id
if self.mask_tool_output:
with self._secure_result_store_lock:
self._secure_result_store[tool_call_id] = raw_result
result = (
"[The tool has been executed successfully, but the "
"output from the tool is masked. You can move forward]"
)
else:
result = raw_result
tool_record = self._record_tool_calling(
func_name,
args,
result,
tool_call_id,
mask_output=self.mask_tool_output,
extra_content=tool_call_request.extra_content,
)
self._tool_call_records.append(tool_record)
def finalize_response(self, model_response: Any) -> ChatAgentResponse:
if self._used_prompt_formatting and self._original_response_format:
self._apply_prompt_based_parsing(
model_response, self._original_response_format
)
self._record_final_output(model_response.output_messages)
if self.prune_tool_calls_from_memory and self._tool_call_records:
self.memory.clean_tool_calls()
return self._convert_to_chatagent_response(
model_response,
self._tool_call_records,
self._accumulated_context_tokens,
None,
self._step_token_usage["prompt_tokens"],
self._step_token_usage["completion_tokens"],
self._step_token_usage["total_tokens"],
)
+217
View File
@@ -0,0 +1,217 @@
import json
import random
from typing import Any, Dict, List, Optional
PRM_SYSTEM = """You are an evaluator for a terminal agent.
You are provided with:
1) the agent's task instruction,
2) the interaction history, and
3) the agent's most recent step to evaluate.
"""
USER_INSTRUCTION = """
Evaluate ONLY the single most recent step using the information above.
Assign a score of +1 if ALL of the following are true:
- The current assistant message is a correct/helpful step that advances the task;
- The tool-call format is valid;
- Tool usage is appropriate for the step;
- Tool results (if any) are consistent with making progress.
Otherwise assign a score of -1, for example if:
- The step is incorrect, misleading, or does not advance the task;
- Tool-call format is broken (invalid JSON / parse error);
- Tool usage is clearly wrong or irrelevant;
- Tool results show failure or clearly no progress.
Think carefully, then provide your reasoning and put the final score in \\boxed{}.
"""
import re
_PRM_BOXED_PATTERN = re.compile(r"\\boxed\{((?:[^{}]|\{[^{}]*\})*)\}", re.DOTALL)
_PRM_STRICT_NUMBER_PATTERN = re.compile(r"^\s*([-+]?\d+(?:\.\d+)?)\s*$")
def _extract_prm_sign_from_text(text: str) -> int:
if not text:
return 0
match = _PRM_BOXED_PATTERN.search(text)
if not match:
return 0
boxed_content = match.group(1).strip()
strict_number_match = _PRM_STRICT_NUMBER_PATTERN.fullmatch(boxed_content)
if not strict_number_match:
return 0
try:
value = float(strict_number_match.group(1))
except ValueError:
return 0
if abs(value - 1.0) < 1e-9:
return 1
if abs(value + 1.0) < 1e-9:
return -1
return 0
def _truncate(text: str, limit: int = 2000) -> str:
if not text:
return ""
return text if len(text) <= limit else (text[:limit] + "...<truncated>")
class TerminalPRMAgent:
"""
PRM agent.
history_mode:
- "last": last k turns
- "random": random sample k turns
- "head_tail": first k turns + last k turns
"""
def __init__(
self,
*,
sglang_client,
task_instruction: str,
history_k: int = 3,
history_mode: str = "head_tail",
head_k: int = 2,
tail_k: int = 2,
history_include_assistant: bool = False,
current_truncate: int = 2000,
history_truncate: int = 5000, # characters not tokens
):
self._sglang_client = sglang_client
self.task_instruction = task_instruction
self.history_k = history_k
self.history_mode = history_mode
self.head_k = head_k
self.tail_k = tail_k
self.history_include_assistant = history_include_assistant
self.current_truncate = current_truncate
self.history_truncate = history_truncate
self._history: Dict[int, Dict[str, Any]] = {}
def record_model_turn(
self,
turn_idx: int,
*,
assistant_text: str,
tool_calls: Optional[List[Dict[str, Any]]] = None,
parse_error_recorded: bool = False,
finish_reason: Optional[str] = None,
) -> None:
rec = self._history.setdefault(turn_idx, {})
rec["assistant_text"] = assistant_text
rec["tool_calls"] = tool_calls
rec["parse_error_recorded"] = bool(parse_error_recorded)
rec["finish_reason"] = finish_reason
rec.setdefault("tool_results", [])
def record_tool_result(
self, turn_idx: int, tool_call_request, raw_result: Any
) -> None:
rec = self._history.setdefault(turn_idx, {})
lst = rec.setdefault("tool_results", [])
lst.append(
{
"name": tool_call_request.tool_name,
"args": tool_call_request.args,
"result": raw_result,
}
)
def get_history(self, current_turn_idx: int) -> List[Dict[str, Any]]:
prev = sorted(t for t in self._history.keys() if t < current_turn_idx)
if not prev:
return []
if self.history_mode == "last":
if self.history_k <= 0:
return []
chosen = prev[-self.history_k :]
elif self.history_mode == "random":
if self.history_k <= 0:
return []
k = min(self.history_k, len(prev))
chosen = random.sample(prev, k=k)
chosen = sorted(chosen)
elif self.history_mode == "head_tail":
head = prev[: max(0, self.head_k)]
tail = prev[-max(0, self.tail_k) :] if self.tail_k > 0 else []
# de-dup while preserving order
seen = set()
chosen = []
for t in head + tail:
if t not in seen:
seen.add(t)
chosen.append(t)
else:
raise ValueError(f"Invalid history mode: {self.history_mode}")
hist: List[Dict[str, Any]] = []
for t in chosen:
r = self._history.get(t, {})
item = {
"turn_idx": t,
"assistant_text": (
_truncate(r["assistant_text"], self.history_truncate)
if self.history_include_assistant
else "[OMITTED]"
),
"tool_calls": r["tool_calls"],
"tool_results": r["tool_results"],
"parse_error_recorded": r["parse_error_recorded"],
}
hist.append(item)
return hist
def _build_messages(self, turn_idx: int) -> List[Dict[str, str]]:
cur = self._history.get(turn_idx, {})
history = self.get_history(turn_idx)
payload = {
"task_instruction": self.task_instruction,
"history": history,
"current": {
"turn_idx": turn_idx,
"assistant_text": _truncate(
cur["assistant_text"], self.current_truncate
),
"tool_calls": cur["tool_calls"],
"tool_results": cur["tool_results"],
"parse_error_recorded": cur["parse_error_recorded"],
},
}
return [
{"role": "system", "content": PRM_SYSTEM},
{
"role": "user",
"content": json.dumps(payload, ensure_ascii=False)
+ "\n\n"
+ USER_INSTRUCTION,
},
]
async def judge_turn(self, turn_idx: int) -> int:
messages = self._build_messages(turn_idx)
_cc, interaction = await self._sglang_client.generate_turn(
messages=messages,
tools=None,
turn_idx=turn_idx,
)
text = interaction.output_text
score = _extract_prm_sign_from_text(text[-50:])
return text, score
+149
View File
@@ -0,0 +1,149 @@
def get_developer_agent_prompt(current_date:str, system:str, machine:str, is_workforce:bool, non_think_mode:bool=True):
"""
Generate the prompt for the Lead Software Engineer agent.
Args:
current_date (str): The current date.
system (str): The operating system. (e.g., "Linux", "Darwin", "Windows", "Linux (in Docker)"...)
machine (str): The machine type. (e.g., "x86_64", "arm64")
is_workforce (bool): Whether the agent is part of a workforce with other agents or standalone.
Returns:
str: The prompt for the Lead Software Engineer agent.
"""
LEAD_SDE_ROLE_PROMPT = f"""
<role>
You are a Lead Software Engineer, a master-level coding assistant with a
powerful and unrestricted terminal. Your primary role is to solve any
technical task by analyzing the problem, making plans,
writing and executing code, installing necessary libraries,
interacting with the operating system, and deploying applications. You are the
team's go-to expert for all technical implementation.
</role>
"""
TEAM_STRUCTURE_PROMPT = f""
OPERATING_ENVIRONMENT_PROMPT = f"""
<operating_environment>
- **System**: {system} ({machine}).
""" \
+ \
("""
Note that the terminal commands and file system operations you perform will be
executed inside a Docker container. But note taking tools will operate on the host system.
""") if "Docker" in system else ""\
+ \
f"""
- **Current Date**: {current_date}.
</operating_environment>
"""
MANDATORY_INSTRUCTIONS_PROMPT = f"""
<mandatory_instructions>
- You MUST use analyze, plan and review requirements and your work.
- When you complete your task, your final response must be a comprehensive
summary of your work and the outcome, presented in a clear, detailed, and
easy-to-read format. Avoid using markdown tables for presenting data; use
plain text formatting instead.
- You MUST use tools and follow tool schemas precisely for every response,
- You MUST be concise about your reasoning and planning, and limit within 600 tokens.
- You MUST try diverse tools available in toolkits.
</mandatory_instructions>
"""
CAPABILITIES_PROMPT = """
<capabilities>
Your capabilities are extensive and powerful:
- **Unrestricted Code Execution**: You can write and execute code in any
language to solve a task.
- For multi-line code, You MUST use tool (shell_write_content_to_file) to first save your code
to somewhere on the system (e.g.,`script.py`) and then run it from the terminal (e.g.,
`python script.py`). Beware of the code that includes quotes\"\'; ensure proper
escaping when writing arguments for toolkit. Make sure it can be parsed by JSON.
- **Full Terminal Control**: You have root-level access to the terminal. You
can run any command-line tool, manage files, and interact with the OS. If
a tool is missing, you MUST install it with the appropriate package manager
(e.g., `pip3`, `uv`, or `apt-get`). Your capabilities include:
- **Text & Data Processing**: `awk`, `sed`, `grep`, `jq`.
- **File System & Execution**: `find`, `xargs`, `tar`, `zip`, `unzip`,
`chmod`.
- **Networking & Web**: `curl`, `wget` for web requests; `ssh` for
remote access.
- **IMPORTANT**: Always complete the full automation workflow—do not just
prepare or suggest actions. Execute them to completion.
- **Solution Verification**: You can immediately test and verify your
solutions by executing them in the terminal.
""" + \
"""
</capabilities>
"""
PHILOSOPHY_PROMPT = """
<philosophy>
- **Bias for Action**: Your purpose is to take action. Don't just suggest
solutions—implement them. Write code, run commands, and build things.
- **Complete the Full Task**: When automating GUI applications, always finish
what you start. If the task involves sending something, send it. If it
involves submitting data, submit it. Never stop at just preparing or
drafting—execute the complete workflow to achieve the desired outcome.
- **Embrace Challenges**: Never say "I can't." If you
encounter a limitation, find a way to overcome it.
- **Resourcefulness**: If a tool is missing, install it. If information is
lacking, find it. You have the full power of a terminal to acquire any
resource you need.
- **Think Like an Engineer**: Approach problems methodically. Analyze
requirements, execute it, and verify the results. Your
strength lies in your ability to engineer solutions.
- ** Use Absolute Paths**: You can access files from any place in the file
system. For all file system operations, you MUST use absolute paths to ensure
precision and avoid ambiguity.
- ** Check current directory**: Always check your current directory with `pwd` and list
files with `ls -la` before performing file operations. This helps you
understand your context and avoid mistakes.
- ** Search for Files**: If you need a file but cannot find it in the current directory,
use commands like `find / -name "filename"` or search in directories common for the System
to locate it anywhere in the file system. This ensures you can always access the resources you need.
- ** Adhere to the initial task instruction**: Always keep the original task instruction in mind, make sure to understand
all requirements and useful information. Make sure finish every subtask mentioned in the instruction.
</philosophy>
"""
SHELL_TIPS_PROMPT = f"""
<terminal_tips>
The terminal tools are session-based, identified by a unique `id`. Master
these tips to maximize your effectiveness:
- **Command-Line Best Practices**:
- **Be Creative**: The terminal is your most powerful tool. Use it boldly.
- **Automate Confirmation**: Use `-y` or `-f` flags to avoid interactive
prompts.
- **Manage Output**: Redirect long outputs to a file (e.g., `> output.txt`).
- **Chain Commands**: Use `&&` to link several commands for sequential execution.
But also avoid chaining too many commands in one line
to avoid json parse errors due to complex escaping issues.
- **Piping**: Use `|` to pass output from one command to another.
- **Permissions**: Use `ls -F` to check file permissions.
- **Installation**: Use `pip3 install` or `apt-get install` for new
packages.
- **Time Management**: `shell_exec` commands come with block or non-block mode. The block mode
has a time limit, and only suitable for very quick commands. If you expect a command to take a long time, or
you have experienced a timeout for a command, you MUST use non-block mode by setting `block=False`.
The non-block mode allows commands to run in the background. You can check the status using `shell_view`,
send in further input using `shell_write_to_process`, and kill it using `shell_kill_process` if needed.
</terminal_tips>
"""
COLLABORATION_AND_ASSISTANCE_PROMPT = f"""
"""
FINAL_INSTRUCTIONS_PROMPT = f"""
{LEAD_SDE_ROLE_PROMPT}
{TEAM_STRUCTURE_PROMPT}
{OPERATING_ENVIRONMENT_PROMPT}
{MANDATORY_INSTRUCTIONS_PROMPT}
{CAPABILITIES_PROMPT}
{PHILOSOPHY_PROMPT}
{SHELL_TIPS_PROMPT}
{COLLABORATION_AND_ASSISTANCE_PROMPT}
"""
if non_think_mode:
FINAL_INSTRUCTIONS_PROMPT = rf"{FINAL_INSTRUCTIONS_PROMPT} /no_think"
return FINAL_INSTRUCTIONS_PROMPT
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Protocol
from custom_types import TurnContext, TurnResult
from inference_client import SGLangTurnClient
class RolloutAgent(Protocol):
@property
def parse_error_count(self) -> int: ...
def set_max_parse_errors(self, max_parse_errors: int) -> None: ...
def start_turn_loop(self, input_message: Any) -> None: ...
async def get_turn_context(
self,
) -> tuple[Optional[List[dict[str, Any]]], Optional[Any]]: ...
async def consume_completion(
self, chat_completion: Any
) -> tuple[Optional[Any], List[Any], bool, Optional[Any]]: ...
def record_tool_result(self, tool_call_request: Any, raw_result: Any) -> None: ...
def finalize_response(self, model_response: Any) -> Any: ...
class AgentRunner:
def __init__(
self,
*,
rollout_agent: RolloutAgent,
sglang_client: SGLangTurnClient,
tool_schemas: List[Dict[str, Any]],
) -> None:
self._rollout_agent = rollout_agent
self._sglang_client = sglang_client
self._tool_schemas = tool_schemas
self._model_turn_count = 0
self._max_iterations = 10
self._max_parse_errors = 3
@property
def model_turn_count(self) -> int:
return self._model_turn_count
@property
def parse_error_count(self) -> int:
return self._rollout_agent.parse_error_count
@property
def max_iterations(self) -> int:
return self._max_iterations
@property
def max_parse_errors(self) -> int:
return self._max_parse_errors
def reset(self, input_message: Any) -> None:
self._model_turn_count = 0
self._rollout_agent.start_turn_loop(input_message)
def set_max_parse_errors(self, max_parse_errors: int) -> None:
self._max_parse_errors = max(1, int(max_parse_errors))
self._rollout_agent.set_max_parse_errors(self._max_parse_errors)
def set_max_iterations(self, max_iterations: int) -> None:
self._max_iterations = max(1, int(max_iterations))
def reached_iteration_limit(self) -> bool:
return self._model_turn_count >= self._max_iterations
def reached_parse_error_limit(self) -> bool:
return self.parse_error_count >= self._max_parse_errors
async def get_turn_context(self) -> TurnContext:
messages, terminated = await self._rollout_agent.get_turn_context()
return TurnContext(context_messages=messages, terminated_response=terminated)
async def run_model_turn(
self, context_messages: List[dict[str, Any]]
) -> TurnResult:
chat_completion, interaction = await self._sglang_client.generate_turn(
messages=context_messages,
tools=self._tool_schemas,
turn_idx=self._model_turn_count,
)
self._model_turn_count += 1
model_response, tool_call_requests, parse_error_recorded, terminated = (
await self._rollout_agent.consume_completion(chat_completion)
)
return TurnResult(
interaction=interaction,
model_response=model_response,
tool_call_requests=tool_call_requests,
parse_error_recorded=parse_error_recorded,
terminated_response=terminated,
)
def record_tool_result(self, tool_call_request: Any, raw_result: Any) -> None:
self._rollout_agent.record_tool_result(tool_call_request, raw_result)
def finalize_response(self, model_response: Any) -> Any:
return self._rollout_agent.finalize_response(model_response)
def create_agent_runner(
*,
agent_type: str,
sglang_client: SGLangTurnClient,
model_type: str,
tool_schemas: List[Dict[str, Any]],
non_think_mode: bool,
max_total_tokens: int,
) -> AgentRunner:
if agent_type == "camel_agent":
from agent.camel_agent import CamelAgent
rollout_agent = CamelAgent(
model_type=model_type,
sglang_client=sglang_client,
non_think_mode=non_think_mode,
max_total_tokens=max_total_tokens,
)
else:
raise ValueError(
f"Unsupported agent type: {agent_type!r}. Expected 'camel_agent'."
)
return AgentRunner(
rollout_agent=rollout_agent,
sglang_client=sglang_client,
tool_schemas=tool_schemas,
)
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional, List
@dataclass(frozen=True)
class TaskSpec:
task_name: str
task_path: str
instruction: str
@dataclass(frozen=True)
class RunContext:
uid: str
group_index: int
sample_index: int
log_dir: Path
def to_payload(self) -> dict[str, Any]:
return {
"uid": self.uid,
"group_index": self.group_index,
"sample_index": self.sample_index,
"log_dir": str(self.log_dir),
}
@dataclass
class TaskTimeouts:
ensure_image: float = 300.0
reset_session: float = 300.0
close_session: float = 60.0
eval: float = 600.0
def to_payload(self) -> dict[str, float]:
return {
"ensure_image": float(self.ensure_image),
"reset_session": float(self.reset_session),
"close_session": float(self.close_session),
"eval": float(self.eval),
}
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_message_param import (
ChatCompletionMessageParam as OpenAIMessage,
)
@dataclass
class Interaction:
turn_idx: int = 0
completion: ChatCompletion | None = None
input_ids: list[int] = field(default_factory=list)
output_token_ids: list[int] = field(default_factory=list)
output_token_logprobs: list[float] = field(default_factory=list)
output_text: str = ""
finish_reason: str = ""
messages: list[OpenAIMessage] = field(default_factory=list)
latency_ms: float = 0.0
@dataclass
class TurnContext:
context_messages: Optional[List[dict[str, Any]]]
terminated_response: Optional[Any] = None
@dataclass
class TurnResult:
interaction: Interaction
model_response: Optional[Any]
tool_call_requests: List[Any]
parse_error_recorded: bool
terminated_response: Optional[Any] = None
@@ -0,0 +1,172 @@
"""Convert Terminal Bench tasks to RLLM/VERL format."""
import os
import json
import pandas as pd
from pathlib import Path
from typing import List, Optional
from tqdm import tqdm
# Add project to path
import sys
from load_tasks import TBenchTrainingTask, load_terminal_bench_tasks
DATASET_DIR = Path(os.getenv("DATASET_DIR", "./terminal-rl/dataset"))
def convert_tasks(
tasks_dir: List[Path],
train_split: Optional[float] = None,
system_prompt: Optional[str] = None,
task_names: Optional[List[str]] = None,
test_tasks_dir: Optional[Path] = None,
format: Optional[str] = "jsonl",
output_dir: Optional[Path] = None,
) -> None:
"""Convert terminal bench tasks to parquet format for VERL training.
Args:
tasks_dir: Directory containing terminal bench tasks (or train tasks if test_tasks_dir is provided)
train_split: Fraction of data for training (ignored if test_tasks_dir is provided)
system_prompt: System prompt to use
task_names: Specific task names to convert
test_tasks_dir: Directory containing test tasks for validation set
"""
# Load tasks
print(f"Loading tasks from {tasks_dir}")
tasks = []
for dir_path in tasks_dir:
tasks.extend(load_terminal_bench_tasks(dir_path, task_names))
print(f"Loaded {len(tasks)} tasks")
if output_dir is None:
output_dir = DATASET_DIR
# Create output directory
output_dir.mkdir(parents=True, exist_ok=True)
# Prepare data for parquet
data_records = []
print(f"len tasks {len(tasks)}")
for task in tqdm(tasks, desc="Converting tasks"):
# find path relative to outdir
task_path = task.task_path.relative_to(DATASET_DIR)
print(f"Processing task: {task.task_name} at {task_path}")
record = {
"task_name": task.task_name,
"task_path": str(task_path),
"instruction": task.instruction,
"data_source": "terminal_bench", # For reward_fn_key
}
data_records.append(record)
# Create DataFrame
df = pd.DataFrame(data_records)
# Split into train and validation
# Use train_split parameter
if train_split is None:
train_split = 1.0
n_train = int(len(df) * train_split)
train_df = df[:n_train]
val_df = df[n_train:]
# Save to file(s)
train_path = output_dir / f"train.{format}"
val_path = output_dir / f"val.{format}"
if format == "jsonl":
train_wrapped = pd.DataFrame({"task": train_df.to_dict(orient="records")})
val_wrapped = pd.DataFrame({"task": val_df.to_dict(orient="records")})
train_wrapped.to_json(
train_path, orient="records", lines=True, force_ascii=False
)
val_wrapped.to_json(val_path, orient="records", lines=True, force_ascii=False)
elif format == "parquet":
train_df.to_parquet(train_path, index=False)
val_df.to_parquet(val_path, index=False)
print(f"Saved {len(train_df)} training examples to {train_path}")
print(f"Saved {len(val_df)} validation examples to {val_path}")
def main(tasks_dir, test_tasks_dir, train_split, depth, output_dir):
# Check if directories exist
if not tasks_dir.exists():
print(f"Error: {tasks_dir} directory not found")
return
if depth == 0:
tasks_dirs = [tasks_dir]
elif depth == 1:
tasks_dirs = [p for p in tasks_dir.iterdir() if p.is_dir()]
print(f"Found {tasks_dirs} task directories at depth {depth}")
if output_dir is None:
output_dir = DATASET_DIR / f"{tasks_dir.name}_convert"
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Writing converted dataset to {output_dir}")
# Convert to parquet only (with extra_info)
convert_tasks(
tasks_dir=tasks_dirs,
train_split=train_split,
system_prompt=None,
task_names=None,
test_tasks_dir=test_tasks_dir,
format="jsonl",
output_dir=output_dir,
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Convert Terminal Bench tasks to RLLM/VERL dataset format."
)
parser.add_argument(
"--tasks_dir",
type=str,
help="Directory containing terminal bench tasks (or train tasks if --test_tasks_dir is provided)",
)
parser.add_argument(
"--test_tasks_dir",
type=str,
default=None,
help="Directory containing test tasks for validation set (if provided)",
)
parser.add_argument(
"--train_split",
type=float,
default=1.0,
help="Fraction of data for training (ignored if --test_tasks_dir is provided)",
)
parser.add_argument(
"-d",
"--depth",
type=int,
default=0,
help="Depth of directory traversal ",
)
parser.add_argument(
"--output_dir",
type=str,
default=None,
help="Output directory for converted dataset (default: <DATASET_DIR>/<tasks_dir_name>_convert)",
)
args = parser.parse_args()
main(
tasks_dir=Path(args.tasks_dir),
test_tasks_dir=Path(args.test_tasks_dir) if args.test_tasks_dir else None,
train_split=args.train_split,
depth=args.depth,
output_dir=args.output_dir,
)
+139
View File
@@ -0,0 +1,139 @@
# Script to download and prepare currently supported datasets for terminal agent training
import os
import shutil
import subprocess
from pathlib import Path
DATASET_DIR = Path(os.getenv("DATASET_DIR", "./terminal-rl/dataset"))
def _download_github_folder(
repo_url, sparse_path, target_dir, branch="main", temp_suffix="temp"
):
"""
General function to download a specific folder from a GitHub repository.
Args:
repo_url: GitHub repository URL (.git)
sparse_path: Path within the repo to download
target_dir: Local destination directory
branch: Git branch to checkout (default: "main")
temp_suffix: Suffix for temporary directory name
"""
if target_dir.exists():
print(f"Dataset already exists at {target_dir}. Skipping download.")
return
DATASET_DIR.mkdir(parents=True, exist_ok=True)
temp_dir = DATASET_DIR / f"temp_{temp_suffix}"
try:
# Clone with sparse checkout
subprocess.run(
[
"git",
"clone",
"--depth",
"1",
"--filter=blob:none",
"--sparse",
repo_url,
str(temp_dir),
"-b",
branch,
],
check=True,
)
subprocess.run(
["git", "-C", str(temp_dir), "sparse-checkout", "set", sparse_path],
check=True,
)
# Move downloaded folder to target location
shutil.move(str(temp_dir / sparse_path), str(target_dir))
print(f"Successfully downloaded to {target_dir}")
finally:
if temp_dir.exists():
shutil.rmtree(temp_dir)
def download_seta_env():
url = "https://github.com/camel-ai/seta-env.git"
target_dir = DATASET_DIR / "seta_env"
_download_github_folder(
url, "Dataset", target_dir, branch="main", temp_suffix="seta_env"
)
def download_tbench_core():
url = "https://github.com/laude-institute/terminal-bench.git"
target_dir = DATASET_DIR / "tbench_core"
_download_github_folder(
url, "tasks", target_dir, branch="main", temp_suffix="tbench_core"
)
def download_tbench_test():
url = "https://github.com/laude-institute/terminal-bench.git"
target_dir = DATASET_DIR / "tbench_test"
_download_github_folder(
url,
"tasks",
target_dir,
branch="dataset/terminal-bench-core/v0.1.x",
temp_suffix="tbench_test",
)
def download_tbench_adapted():
url = "https://github.com/laude-institute/terminal-bench-datasets.git"
raw_dir = DATASET_DIR / "tbench_adapted_raw"
target_dir = DATASET_DIR / "tbench_adapted"
if target_dir.exists():
print(f"Dataset already exists at {target_dir}. Skipping download.")
return
# Download the raw datasets
_download_github_folder(
url, "datasets", raw_dir, branch="main", temp_suffix="tbench_adapted"
)
# Create target directory
target_dir.mkdir(parents=True, exist_ok=True)
# Create symbolic links with prefixed names
for subfolder in raw_dir.iterdir():
if subfolder.is_dir():
subfolder_name = subfolder.name
for task_folder in subfolder.iterdir():
if task_folder.is_dir():
task_name = task_folder.name
prefixed_name = f"{subfolder_name}_{task_name}"
symlink_path = target_dir / prefixed_name
symlink_path.symlink_to(task_folder, target_is_directory=True)
print(f"Successfully created symlinks in {target_dir}")
def download_data(ds_name):
DATASET_DOWNLOADERS = {
"seta_env": download_seta_env,
"tbench_core": download_tbench_core,
"tbench_test": download_tbench_test,
"tbench_adapted": download_tbench_adapted,
}
if ds_name not in DATASET_DOWNLOADERS:
raise ValueError(f"Dataset {ds_name} is not supported.")
DATASET_DOWNLOADERS[ds_name]()
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
download_data(sys.argv[1])
else:
print("Available datasets: seta_env, tbench_core, tbench_test, tbench_adapted")
print("Usage: python download_data.py <dataset_name>")
+119
View File
@@ -0,0 +1,119 @@
import json
from pathlib import Path
from typing import List, Optional
from pydantic import BaseModel
import yaml
class TBenchTrainingTask(BaseModel):
"""Data model for a task which follows the same format as terminal-bench."""
task_name: str
task_path: Path
instruction: str
# test_weights: dict
# dockerfile_contents: str
# py_test_file_contents: str
# max_test_timeout_sec: int = 300 # Default timeout
# additional_files: Optional[dict] = None # Maps file paths to contents
def load_terminal_bench_tasks(
tasks_dir: Path,
task_names: Optional[List[str]] = None,
) -> List[TBenchTrainingTask]:
if task_names is None:
print(f"tasks_dir {tasks_dir}")
import os
print(f"len {len(os.listdir(str(tasks_dir)))}")
task_names = [p.name for p in tasks_dir.iterdir() if p.is_dir()]
print(f"task names {task_names}")
tasks = []
from tqdm import tqdm
for task_name in tqdm(task_names):
try:
task_path = tasks_dir / task_name
task_yaml = task_path / "task.yaml"
if not task_yaml.exists():
raise FileNotFoundError(f"Task YAML file not found: {task_yaml}")
with open(task_yaml, "r", encoding="utf-8") as f:
task_data = yaml.safe_load(f)
instruction = task_data.get("instruction")
if not instruction:
raise ValueError(f"Instruction not found in task YAML: {task_yaml}")
# # Get max test timeout if specified
# max_test_timeout_sec = task_data.get("max_test_timeout_sec", 300)
# # Load test weights
# test_weights_path = task_path / "test_weights.json"
# if test_weights_path.exists():
# with open(test_weights_path, "r", encoding="utf-8") as f:
# test_weights = json.load(f)
# else:
# test_weights = {"default": 1.0}
# # Load Dockerfile
# dockerfile_path = task_path / "Dockerfile"
# if not dockerfile_path.exists():
# continue
# with open(dockerfile_path, "r", encoding="utf-8") as f:
# dockerfile_contents = f.read()
# # Load Python test file if it exists
# py_test_file_path = task_path / "tests" / "test_outputs.py"
# if not py_test_file_path.exists():
# # raise FileNotFoundError(f"Python test file not found: {py_test_file_path}")
# continue
# with open(py_test_file_path, "r", encoding="utf-8") as f:
# py_test_file_contents = f.read()
# if not py_test_file_contents:
# # raise ValueError(f"Python test file is empty for task: {task_name}")
# continue
# # Load additional files if they exist
# additional_files = {}
# # List all files in the task directory (excluding standard files)
# standard_files = {'Dockerfile', 'task.yaml', 'test_weights.json'}
# standard_dirs = {'tests', '__pycache__'}
# for item in task_path.iterdir():
# if item.is_file() and item.name not in standard_files:
# # Read the file and store with relative path
# rel_path = item.relative_to(task_path)
# with open(item, "r", encoding="utf-8") as f:
# additional_files[str(rel_path)] = f.read()
# elif item.is_dir() and item.name not in standard_dirs:
# # Recursively read files from subdirectories
# for subfile in item.rglob("*"):
# if subfile.is_file():
# rel_path = subfile.relative_to(task_path)
# try:
# with open(subfile, "r", encoding="utf-8") as f:
# additional_files[str(rel_path)] = f.read()
# except UnicodeDecodeError:
# # Skip binary files for now
# pass
tasks.append(
TBenchTrainingTask(
task_name=task_name,
task_path=task_path,
instruction=instruction,
# test_weights=None,
# dockerfile_contents=None,
# py_test_file_contents=None,
# max_test_timeout_sec=None,
# additional_files=None,
)
)
except Exception as e:
print(f"Error loading task {task_name}: {e}")
continue
return tasks
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import logging
import os
from typing import Any
from slime.utils.http_utils import post
logger = logging.getLogger(__name__)
class TerminalEnvClient:
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
self.default_max_retries = int(os.getenv("ENV_HTTP_MAX_RETRIES", "10"))
self.allocate_max_retries = int(os.getenv("ENV_ALLOCATE_MAX_RETRIES", "100"))
self.evaluate_max_retries = int(os.getenv("ENV_EVALUATE_MAX_RETRIES", "1"))
self.close_max_retries = int(os.getenv("ENV_CLOSE_MAX_RETRIES", "3"))
self.exec_tool_max_retries = int(os.getenv("ENV_EXEC_TOOL_MAX_RETRIES", "3"))
async def allocate(
self,
task_key: str,
request_id: str | None = None,
) -> dict[str, Any]:
out = await post(
f"{self.base_url}/allocate",
{"task_key": task_key, "request_id": request_id},
max_retries=self.allocate_max_retries,
)
if not out.get("ok", False):
raise RuntimeError(f"allocate failed: {out}")
return out
async def heartbeat(self, lease_id: str) -> None:
out = await post(
f"{self.base_url}/heartbeat",
{"lease_id": lease_id},
max_retries=self.default_max_retries,
)
if not out.get("ok", False):
raise RuntimeError(f"heartbeat failed: {out}")
async def reset(
self,
lease_id: str,
task_meta: dict[str, Any],
run_ctx: dict[str, Any],
task_timeouts: dict[str, Any] | None = None,
) -> dict[str, Any]:
out = await post(
f"{self.base_url}/reset",
{
"lease_id": lease_id,
"task_meta": task_meta,
"run_ctx": run_ctx,
"task_timeouts": task_timeouts,
},
max_retries=self.default_max_retries,
)
if not out.get("ok", False):
raise RuntimeError(f"reset failed: {out}")
return out
async def exec_tool(
self, lease_id: str, tool_name: str, arguments: dict[str, Any]
) -> str:
out = await post(
f"{self.base_url}/exec_tool",
{
"lease_id": lease_id,
"tool_call": {"name": tool_name, "arguments": arguments},
},
max_retries=self.exec_tool_max_retries,
)
if not out.get("ok", False):
raise RuntimeError(f"exec_tool failed: {out}")
return str(out.get("observation", ""))
async def evaluate(self, lease_id: str) -> float:
out = await post(
f"{self.base_url}/evaluate",
{"lease_id": lease_id},
max_retries=self.evaluate_max_retries,
)
if not out.get("ok", False):
raise RuntimeError(f"evaluate failed: {out}")
return float(out.get("score", 0.0))
async def close(self, lease_id: str) -> None:
try:
out = await post(
f"{self.base_url}/close",
{"lease_id": lease_id},
max_retries=self.close_max_retries,
)
except Exception as exc:
error_str = str(exc)
resp_text = ""
if hasattr(exc, "response"):
try:
resp_text = exc.response.text
except Exception:
pass
combined = f"{error_str} {resp_text}"
if "Unknown run_lease_id" in combined or "Unknown lease" in combined:
logger.debug("close(%s): lease already gone, nothing to do.", lease_id)
return
raise
if not out.get("ok", False):
error_msg = str(out.get("error", ""))
if "Unknown" in error_msg and "lease" in error_msg.lower():
logger.debug("close(%s): lease already gone, nothing to do.", lease_id)
return
raise RuntimeError(f"close failed: {out}")
+658
View File
@@ -0,0 +1,658 @@
from __future__ import annotations
import logging
import os
import uuid
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional
import asyncio
from slime.rollout.sglang_rollout import GenerateState
from slime.utils.types import Sample
from agent.prm_agent import TerminalPRMAgent
from custom_types import (
Interaction,
RunContext,
TaskSpec,
TaskTimeouts,
TurnContext,
TurnResult,
)
from inference_client import SGLangTurnClient
from agent_runner import create_agent_runner
from env_client import TerminalEnvClient
logger = logging.getLogger(__name__)
def _extract_task_meta(sample: Sample) -> Dict[str, Any]:
if isinstance(sample.prompt, dict):
return sample.prompt
metadata = sample.metadata or {}
task_meta = metadata.get("task_meta") if isinstance(metadata, dict) else None
if isinstance(task_meta, dict):
return task_meta
if isinstance(metadata, dict):
return metadata
return {}
def _make_task_spec(meta: Dict[str, Any]) -> TaskSpec:
return TaskSpec(
task_name=meta.get("task_name", "unknown"),
task_path=meta.get("task_path", ""),
instruction=meta.get("instruction", ""),
)
def _build_samples(
interactions: List[Interaction],
base_sample: Sample,
outcome: float,
status: Sample.Status,
prm_turn_scores: dict[int, float] | None = None,
prm_coef: float = 1.0,
discount: float = 1.0,
encourage: bool = False,
) -> List[Sample]:
"""Create one Sample per interaction with discounted reward."""
num_turns = len(interactions)
samples: List[Sample] = []
accuracy = float(outcome)
raw_score = accuracy + (accuracy == 1.0) * int(encourage)
base_outcome = 2.0 * accuracy - 1.0
for interaction in interactions:
turn_idx = interaction.turn_idx
s = deepcopy(base_sample)
s.tokens = interaction.input_ids + interaction.output_token_ids
s.response_length = len(interaction.output_token_ids)
s.loss_mask = [1] * s.response_length
s.rollout_log_probs = list(interaction.output_token_logprobs)
s.response = interaction.output_text
s.status = status
s.metadata.update(
{
"turn_idx": turn_idx,
"num_turns": num_turns,
"finish_reason": interaction.finish_reason,
"latency_ms": interaction.latency_ms,
}
)
steps_from_end = num_turns - 1 - turn_idx
discounted_base = base_outcome * (discount**steps_from_end)
if prm_turn_scores is not None:
prm = prm_turn_scores.get(turn_idx, 0.0)
final = discounted_base + prm_coef * prm
s.metadata["step_wise"] = {
"step_scores": [prm],
"step_scores_with_outcome": [final],
"step_indices": [turn_idx],
"step_token_spans": [[0, s.response_length]],
}
else:
final = discounted_base
s.reward = {
"accuracy": accuracy,
"raw_score": raw_score,
"base_score": discounted_base,
"score": final,
}
if prm_turn_scores is not None:
s.reward["prm_turn_score"] = prm
samples.append(s)
return samples
def _mark_non_trainable_samples(samples: List[Sample]) -> None:
for sample in samples:
if sample.status in {Sample.Status.ABORTED, Sample.Status.FAILED}:
if sample.reward is None:
sample.reward = {"score": 0.0}
sample.remove_sample = True
def _infer_completion_budget(sampling_params: Dict[str, Any]) -> int:
for key in ("max_new_tokens", "max_tokens", "max_completion_tokens"):
raw_value = sampling_params.get(key)
if raw_value is None:
continue
try:
parsed = int(raw_value)
except (TypeError, ValueError):
continue
if parsed > 0:
return parsed
return 0
def _normalize_tool_schemas(raw_tools: List[Any]) -> List[Dict[str, Any]]:
schemas: List[Dict[str, Any]] = []
for tool in raw_tools:
if hasattr(tool, "get_openai_tool_schema") and callable(
tool.get_openai_tool_schema
):
schemas.append(tool.get_openai_tool_schema())
elif isinstance(tool, dict):
schemas.append(tool)
else:
raise TypeError(f"Unsupported tool schema object type: {type(tool)!r}")
return schemas
async def _create_env_client(
task_spec: TaskSpec,
run_ctx: RunContext,
) -> tuple[TerminalEnvClient, str]:
env_server_url = os.getenv("ENV_SERVER_URL", "")
if not env_server_url:
raise RuntimeError("ENV_SERVER_URL is empty.")
env_client = TerminalEnvClient(env_server_url)
task_key = f"{task_spec.task_name}:{task_spec.task_path}"
request_id = (
f"{task_key}:{run_ctx.uid}:{run_ctx.group_index}:{run_ctx.sample_index}"
)
lease = await env_client.allocate(task_key=task_key, request_id=request_id)
lease_id = str(lease["lease_id"])
logger.info(
"Using remote terminal env backend lease=%s server=%s", lease_id, env_server_url
)
return env_client, lease_id
def _create_sglang_client(
args: Any,
tokenizer: Any,
sampling_params: Dict[str, Any],
max_total_tokens: int,
enable_sglang_non_think: bool,
*,
sglang_url: str | None = None,
max_retries: int = 30,
) -> SGLangTurnClient:
if not sglang_url:
sglang_url = (
f"http://{args.sglang_router_ip}:{args.sglang_router_port}/generate"
)
client_template_kwargs = {
"chat_template_type": getattr(args, "chat_template_type", "hf"),
"chat_template_kwargs": getattr(args, "chat_template_kwargs", None),
"messages_delimiter_start": getattr(
args, "messages_delimiter_start", "<|im_start|>"
),
"messages_delimiter_end": getattr(args, "messages_delimiter_end", "<|im_end|>"),
"tool_call_parser": getattr(args, "tool_call_parser", "qwen25"),
}
if enable_sglang_non_think:
raw_chat_template_kwargs = client_template_kwargs.get("chat_template_kwargs")
if isinstance(raw_chat_template_kwargs, dict):
merged_chat_template_kwargs = dict(raw_chat_template_kwargs)
else:
merged_chat_template_kwargs = {}
merged_chat_template_kwargs["enable_thinking"] = False
client_template_kwargs["chat_template_kwargs"] = merged_chat_template_kwargs
completion_budget = _infer_completion_budget(sampling_params)
effective_context_limit = max_total_tokens
for maybe_cap in (
getattr(args, "rollout_max_context_len", None),
getattr(args, "sglang_max_context_len", None),
):
try:
parsed_cap = int(maybe_cap)
except (TypeError, ValueError):
continue
if parsed_cap > 0:
effective_context_limit = min(effective_context_limit, parsed_cap)
max_input_tokens = max(1, effective_context_limit - completion_budget)
logger.info(
"SGLang client: url=%s context_limit=%d, completion_budget=%d, max_input_tokens=%d",
sglang_url,
effective_context_limit,
completion_budget,
max_input_tokens,
)
raw_request_timeout = getattr(args, "sglang_request_timeout", None)
if raw_request_timeout in (None, "", 0, 0.0):
raw_request_timeout = os.getenv("SGLANG_REQUEST_TIMEOUT")
try:
request_timeout = (
float(raw_request_timeout) if raw_request_timeout is not None else None
)
except (TypeError, ValueError):
request_timeout = None
if request_timeout is not None and request_timeout <= 0:
request_timeout = None
return SGLangTurnClient(
model_type=None,
tokenizer=tokenizer,
sampling_params=sampling_params,
url=sglang_url,
session_id=None,
max_input_tokens=max_input_tokens,
request_timeout=request_timeout,
max_retries=max_retries,
**client_template_kwargs,
)
async def generate(
args,
sample: Sample,
sampling_params: Dict[str, Any],
evaluation: bool = False,
) -> List[Sample]:
_ = evaluation
state = GenerateState(args)
task_meta = _extract_task_meta(sample)
uid = (sample.metadata or {}).get("uid") or uuid.uuid4().hex[:8]
group_index = int(sample.group_index) if sample.group_index is not None else -1
sample_index = int(sample.index) if sample.index is not None else -1
task_spec = _make_task_spec(task_meta)
run_ctx = RunContext(
uid=uid,
group_index=group_index,
sample_index=sample_index,
log_dir=Path(getattr(args, "tbench_output_root", "build_outputs"))
/ "AgentRunner_Output",
)
run_ctx_payload = run_ctx.to_payload()
timeouts = TaskTimeouts(
ensure_image=getattr(args, "ensure_image_timeout", 300.0),
reset_session=getattr(args, "reset_session_timeout", 300.0),
close_session=getattr(args, "close_session_timeout", 60.0),
eval=getattr(args, "eval_timeout", 600.0),
)
timeouts_payload = timeouts.to_payload()
env_client: Optional[TerminalEnvClient] = None
lease_id: Optional[str] = None
prm_enable = bool(getattr(args, "prm_enable", False)) and (not evaluation)
prm_coef = float(getattr(args, "prm_turn_coef", 1.0))
prm_agent: TerminalPRMAgent | None = None
prm_pending: list[tuple[int, asyncio.Task]] = []
prm_turn_scores: dict[int, float] = {}
prm_turn_details: list[dict[str, Any]] = []
_log_tag = f"[task={task_spec.task_name} uid={run_ctx.uid} group_idx={run_ctx.group_index} sample_idx={run_ctx.sample_index}]"
try:
env_client, lease_id = await _create_env_client(task_spec, run_ctx)
reset_payload = await env_client.reset(
lease_id=lease_id,
task_meta=task_meta,
run_ctx=run_ctx_payload,
task_timeouts=timeouts_payload,
)
user_msg = str(reset_payload.get("user_msg", ""))
raw_tools = list(reset_payload.get("tool_schemas", []))
logger.info("%s Start terminal rollout", _log_tag)
tool_schemas = _normalize_tool_schemas(raw_tools)
agent_type = str(getattr(args, "terminal_agent_type", "camel_agent"))
model_type = str(getattr(args, "model_type", "slime-sglang"))
non_think_mode = bool(getattr(args, "non_think_mode", True))
non_think_mode_source = str(
getattr(args, "non_think_mode_source", "prompt")
).lower()
if non_think_mode_source not in {"prompt", "sglang", "both"}:
non_think_mode_source = "prompt"
enable_prompt_non_think = non_think_mode and non_think_mode_source in {
"prompt",
"both",
}
enable_sglang_non_think = non_think_mode and non_think_mode_source in {
"sglang",
"both",
}
terminal_max_iterations = max(1, int(getattr(args, "max_iteration", 10)))
terminal_max_parse_errors = max(1, int(getattr(args, "max_parse_errors", 3)))
max_total_tokens = int(getattr(args, "max_total_tokens", 32768))
sglang_client = _create_sglang_client(
args=args,
tokenizer=state.tokenizer,
sampling_params=sampling_params,
max_total_tokens=max_total_tokens,
enable_sglang_non_think=enable_sglang_non_think,
)
if prm_enable:
prm_router_ip = getattr(args, "prm_router_ip", None)
prm_router_port = getattr(args, "prm_router_port", None)
if prm_router_ip and prm_router_port:
prm_sglang_url = f"http://{prm_router_ip}:{prm_router_port}/generate"
else:
prm_sglang_url = getattr(args, "prm_sglang_url", None) or os.getenv(
"PRM_SGLANG_URL", ""
)
if not prm_sglang_url:
raise RuntimeError(
"prm_enable=True but no PRM endpoint: set prm_router_ip/port, "
"prm_sglang_url, or PRM_SGLANG_URL env var."
)
prm_sampling_params = {
"temperature": float(getattr(args, "prm_temperature", 0.0)),
"max_new_tokens": int(getattr(args, "prm_max_new_tokens", 4096)),
}
prm_max_total_tokens = int(getattr(args, "prm_max_total_tokens", 16384))
prm_sglang_client = _create_sglang_client(
args=args,
tokenizer=state.tokenizer,
sampling_params=prm_sampling_params,
max_total_tokens=prm_max_total_tokens,
enable_sglang_non_think=True,
sglang_url=prm_sglang_url,
max_retries=10,
)
prm_agent = TerminalPRMAgent(
sglang_client=prm_sglang_client,
task_instruction=task_spec.instruction,
history_mode=str(getattr(args, "prm_history_mode", "head_tail")),
)
logger.info(
"%s PRM enabled: url=%s coef=%.3f", _log_tag, prm_sglang_url, prm_coef
)
agent_runner = create_agent_runner(
agent_type=agent_type,
sglang_client=sglang_client,
model_type=model_type,
tool_schemas=tool_schemas,
non_think_mode=enable_prompt_non_think,
max_total_tokens=max_total_tokens,
)
agent_runner.reset(user_msg)
agent_runner.set_max_parse_errors(terminal_max_parse_errors)
agent_runner.set_max_iterations(terminal_max_iterations)
# Loop
interactions: List[Interaction] = []
final_model_response = None
final_response = None
reached_iteration_limit = False
reached_parse_error_limit = False
while True:
context_result: TurnContext = await agent_runner.get_turn_context()
if context_result.terminated_response is not None:
logger.warning("%s Rollout pre-terminated before model turn.", _log_tag)
final_response = context_result.terminated_response
break
if context_result.context_messages is None:
logger.warning("%s Rollout context is empty; aborting loop.", _log_tag)
break
turn_state: TurnResult = await agent_runner.run_model_turn(
context_result.context_messages
)
interaction = turn_state.interaction
turn_idx = int(interaction.turn_idx)
interactions.append(interaction)
if prm_agent is not None:
tool_calls_for_prm = [
{"tool_name": tc.tool_name, "args": tc.args}
for tc in (turn_state.tool_call_requests or [])
]
prm_agent.record_model_turn(
turn_idx,
assistant_text=interaction.output_text or "",
tool_calls=tool_calls_for_prm or None,
parse_error_recorded=turn_state.parse_error_recorded,
finish_reason=interaction.finish_reason,
)
if turn_state.terminated_response is not None:
logger.warning(
"%s Rollout terminated during model turn %d.", _log_tag, turn_idx
)
final_response = turn_state.terminated_response
break
if turn_state.model_response is None:
logger.warning(
"%s Model turn %d returned empty model_response.",
_log_tag,
turn_idx,
)
break
should_continue_loop = False
if tool_call_requests := turn_state.tool_call_requests:
logger.info(
"%s Turn %d: executing %d tool call(s).",
_log_tag,
turn_idx,
len(tool_call_requests),
)
for tool_call_request in tool_call_requests:
assert env_client is not None and lease_id is not None
await env_client.heartbeat(lease_id)
raw_result = await env_client.exec_tool(
lease_id,
tool_call_request.tool_name,
tool_call_request.args,
)
agent_runner.record_tool_result(tool_call_request, raw_result)
if prm_agent is not None:
prm_agent.record_tool_result(
turn_idx, tool_call_request, raw_result
)
should_continue_loop = True
if turn_state.parse_error_recorded:
logger.warning(
"%s Turn %d: tool-call parse error.",
_log_tag,
turn_idx,
)
should_continue_loop = True
if prm_agent is not None:
task = asyncio.create_task(prm_agent.judge_turn(turn_idx))
prm_pending.append((turn_idx, task))
if should_continue_loop:
if (
turn_state.parse_error_recorded
and agent_runner.reached_parse_error_limit()
):
logger.error(
"%s Max parse errors (%d) reached at turn %d.",
_log_tag,
agent_runner.max_parse_errors,
turn_idx,
)
reached_parse_error_limit = True
final_model_response = turn_state.model_response
break
if agent_runner.reached_iteration_limit():
logger.warning(
"%s Max iterations (%d) reached.",
_log_tag,
agent_runner.max_iterations,
)
reached_iteration_limit = True
final_model_response = turn_state.model_response
break
continue
final_model_response = turn_state.model_response
break
if final_response is None and final_model_response is not None:
final_response = agent_runner.finalize_response(final_model_response)
if final_response is None:
logger.error(
"%s No final response produced; mark sample aborted.", _log_tag
)
sample.status = Sample.Status.ABORTED
sample.remove_sample = True
sample.reward = {"score": 0.0}
return [sample]
finish_reasons = final_response.info.get("termination_reasons", [])
is_aborted = not final_response.msg
if final_response.terminated and "max_tokens_exceeded" in finish_reasons:
status = Sample.Status.TRUNCATED
elif reached_iteration_limit:
status = Sample.Status.TRUNCATED
elif reached_parse_error_limit:
status = Sample.Status.FAILED
elif is_aborted:
status = Sample.Status.ABORTED
else:
status = Sample.Status.COMPLETED
logger.info(
"%s Rollout finished: status=%s turns=%d parse_errors=%d",
_log_tag,
status,
agent_runner.model_turn_count,
agent_runner.parse_error_count,
)
# Evaluation
reward = 0.0
eval_error: str | None = None
should_evaluate = (not is_aborted) and status != Sample.Status.FAILED
if should_evaluate:
try:
assert env_client is not None and lease_id is not None
await env_client.heartbeat(lease_id)
raw_score = await env_client.evaluate(lease_id)
reward = float(raw_score)
logger.info("%s Evaluation reward=%.4f", _log_tag, reward)
except Exception as exc:
eval_error = f"{type(exc).__name__}: {exc}"
status = Sample.Status.FAILED
reward = 0.0
logger.error(
"%s Evaluation failed, marking FAILED: %s",
_log_tag,
eval_error,
)
if not interactions:
logger.warning("%s No interactions recorded; remove sample.", _log_tag)
sample.status = status
sample.remove_sample = True
sample.reward = {"score": 0.0}
return [sample]
if prm_agent is not None and prm_pending:
for turn_idx, prm_task in prm_pending:
try:
output_text, score = await prm_task
prm_turn_scores[turn_idx] = float(score)
prm_turn_details.append(
{
"turn_idx": turn_idx,
"score": float(score),
"output_text": output_text,
}
)
logger.info(
"%s PRM judge turn %d score=%.4f, output_text=%s",
_log_tag,
turn_idx,
float(score),
output_text.replace("\n", ""),
)
except Exception as exc:
logger.warning(
"%s PRM judge failed for turn %d (ignored): %s",
_log_tag,
turn_idx,
exc,
)
prm_turn_scores[turn_idx] = 0.0
prm_turn_details.append(
{"turn_idx": turn_idx, "score": 0.0, "error": str(exc)}
)
if prm_agent is not None:
sample.metadata["prm"] = {
"enabled": True,
"coef": prm_coef,
"turn_scores": prm_turn_scores,
"turn_details": prm_turn_details,
}
# Build training samples
samples = _build_samples(
interactions=interactions,
base_sample=sample,
outcome=reward,
status=status,
prm_turn_scores=(prm_turn_scores if prm_agent is not None else None),
prm_coef=prm_coef,
discount=1.0,
encourage=False,
)
for s in samples:
s.metadata["model_turn_count"] = agent_runner.model_turn_count
s.metadata["parse_error_count"] = agent_runner.parse_error_count
if eval_error is not None:
s.metadata["evaluation_failed"] = True
s.metadata["evaluation_error"] = eval_error
_mark_non_trainable_samples(samples)
return samples
except Exception as exc:
logger.error(
"%s Generate failed (%s): %s",
_log_tag,
type(exc).__name__,
exc,
exc_info=True,
)
sample.status = Sample.Status.FAILED
sample.remove_sample = True
sample.reward = {"score": 0.0}
eos = state.tokenizer.eos_token_id
if eos is None:
sample.tokens = []
sample.response_length = 0
sample.rollout_log_probs = []
sample.loss_mask = []
else:
sample.tokens = [eos, eos]
sample.response_length = 1
sample.rollout_log_probs = [0.0]
sample.loss_mask = [0]
return [sample]
finally:
for _turn_idx, t in prm_pending:
if not t.done():
t.cancel()
if env_client is not None and lease_id is not None:
try:
await env_client.close(lease_id)
except Exception as exc:
logger.debug(
"%s Best-effort remote close failed lease=%s: %s",
_log_tag,
lease_id,
exc,
)
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
import asyncio
import datetime
import inspect
import logging
import time
import traceback
import uuid
from copy import deepcopy
from typing import Any, Dict, List
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_message_function_tool_call import (
ChatCompletionMessageFunctionToolCall,
Function,
)
from openai.types.completion_usage import CompletionUsage
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from slime.utils.http_utils import post as async_post
from custom_types import Interaction
logger = logging.getLogger(__name__)
def process_tool_calls(
text: str,
tools: list[Any],
tool_call_parser: str | None,
finish_reason: str,
use_responses: bool = False,
) -> tuple[
list[ChatCompletionMessageFunctionToolCall | ResponseFunctionToolCall] | None,
str,
str,
]:
from sglang.srt.entrypoints.openai.protocol import Function as SglFunction
from sglang.srt.entrypoints.openai.protocol import Tool as SglTool
from sglang.srt.function_call.function_call_parser import FunctionCallParser
if use_responses:
tools = [
SglTool(
type=tool["type"],
function=SglFunction(
name=tool.get("name"),
description=tool.get("description"),
parameters=tool.get("parameters"),
),
)
for tool in tools
]
else:
tools = [
SglTool(type=tool["type"], function=SglFunction(**tool["function"]))
for tool in tools
]
parser = FunctionCallParser(tools, tool_call_parser)
if parser.has_tool_call(text):
if finish_reason == "stop":
finish_reason = "tool_calls"
try:
text, call_info_list = parser.parse_non_stream(text)
if use_responses:
tool_calls = [
ResponseFunctionToolCall(
type="function_call",
id=f"fc-{uuid.uuid4().hex[:24]}",
call_id=f"call_{uuid.uuid4().hex[:24]}",
name=call_info.name,
arguments=call_info.parameters,
status="completed",
)
for call_info in call_info_list
]
else:
tool_calls = [
ChatCompletionMessageFunctionToolCall(
type="function",
id=f"call_{uuid.uuid4().hex[:24]}",
function=Function(
name=call_info.name, arguments=call_info.parameters
),
)
for call_info in call_info_list
]
return tool_calls, text, finish_reason
except Exception as exc:
logger.error("Tool call parsing error: %s", exc)
traceback.print_exc()
return None, text, finish_reason
return None, text, finish_reason
def _ensure_stop_token_ids(
tokenizer, sampling_params: Dict[str, Any]
) -> Dict[str, Any]:
normalized = dict(sampling_params)
if "stop_token_ids" in normalized:
return normalized
stop_ids: set[int] = set()
if tokenizer.eos_token_id is not None:
stop_ids.add(tokenizer.eos_token_id)
if tokenizer.pad_token_id is not None:
stop_ids.add(tokenizer.pad_token_id)
if stop_ids:
normalized["stop_token_ids"] = list(stop_ids)
return normalized
def _to_positive_int(value: Any) -> int | None:
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
class SGLangTurnClient:
def __init__(
self,
*,
model_type: str | None = None,
tokenizer,
sampling_params: Dict[str, Any],
url: str,
chat_template_type: str = "hf",
chat_template_kwargs: Dict[str, Any] | None = None,
messages_delimiter_start: str = "<|im_start|>",
messages_delimiter_end: str = "<|im_end|>",
session_id: str | None = None,
tool_call_parser: str | None = None,
max_input_tokens: int | None = None,
request_timeout: float | None = None,
max_retries: int = 30,
) -> None:
self.model_type = model_type
self.tokenizer = tokenizer
self.sampling_params = _ensure_stop_token_ids(tokenizer, sampling_params)
self.url = url
self.chat_template_type = chat_template_type
self.chat_template_kwargs = chat_template_kwargs or {}
self.messages_delimiter_start = messages_delimiter_start
self.messages_delimiter_end = messages_delimiter_end
self.session_id = session_id
self.tool_call_parser = tool_call_parser
self.max_input_tokens = _to_positive_int(max_input_tokens)
self.request_timeout = (
request_timeout if (request_timeout and request_timeout > 0) else None
)
self.max_retries = max(1, max_retries)
self.marker = "\n[OMITTED MIDDLE]\n"
self.sep_ids = self.tokenizer.encode(self.marker, add_special_tokens=False)
def _truncate_input_ids(self, input_ids: List[int]) -> List[int]:
max_toks = self.max_input_tokens
if max_toks is None or len(input_ids) <= max_toks:
return input_ids
dropped = len(input_ids) - max_toks
logger.warning(
"Prompt is too long for configured budget: input=%d, budget=%d. Truncating %d token(s) from the left and right.",
len(input_ids),
max_toks,
dropped,
)
keep_head_ratio = getattr(self, "keep_head_ratio", 0.3)
head = max(1, int(max_toks * keep_head_ratio))
tail = max_toks - head - len(self.sep_ids)
if tail <= 0:
logger.warning(
f"tail is not positive: tail={tail}, head={head}, max_toks={max_toks}, len(self.sep_ids)={len(self.sep_ids)}"
)
tail = 1
return input_ids[:head] + self.sep_ids + input_ids[-tail:]
async def generate_turn(
self,
*,
messages: List[dict[str, Any]],
tools: List[dict[str, Any]] | None,
turn_idx: int,
) -> tuple[ChatCompletion, Interaction]:
input_ids = self._apply_chat_template(messages, tools)
input_ids = self._truncate_input_ids(input_ids)
payload: Dict[str, Any] = {
"input_ids": input_ids,
"sampling_params": self.sampling_params,
"return_logprob": True,
}
headers: Dict[str, str] | None = None
if self.session_id:
headers = {"X-SMG-Routing-Key": self.session_id}
t0 = time.monotonic()
supports_headers = "headers" in inspect.signature(async_post).parameters
async def _do_post():
if headers and supports_headers:
return await async_post(
self.url, payload, max_retries=self.max_retries, headers=headers
)
else:
if headers and not supports_headers:
logger.warning(
"async_post() does not accept headers; routing key will be ignored for this request."
)
return await async_post(self.url, payload, max_retries=self.max_retries)
if self.request_timeout:
try:
output = await asyncio.wait_for(
_do_post(), timeout=self.request_timeout
)
except asyncio.TimeoutError:
elapsed = (time.monotonic() - t0) * 1000.0
raise TimeoutError(
f"SGLang generate request timed out after {self.request_timeout}s "
f"(elapsed={elapsed:.0f}ms, turn_idx={turn_idx})"
)
else:
output = await _do_post()
latency_ms = (time.monotonic() - t0) * 1000.0
output_text: str = output["text"]
raw_output_text = output_text
meta_info = output["meta_info"]
finish_reason: str = meta_info["finish_reason"]["type"]
if "output_token_logprobs" in meta_info:
raw_logprobs = meta_info["output_token_logprobs"]
if raw_logprobs and logger.isEnabledFor(logging.DEBUG):
logger.debug(
"output_token_logprobs sample element: %s", raw_logprobs[0]
)
output_token_ids: list[int] = [x[1] for x in raw_logprobs]
output_token_logprobs: list[float] = [x[0] for x in raw_logprobs]
else:
output_token_ids = []
output_token_logprobs = []
tool_calls = None
if tools:
tool_calls, output_text, finish_reason = process_tool_calls(
output_text,
tools,
self.tool_call_parser,
finish_reason,
)
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
current_time = int(datetime.datetime.now().timestamp())
chat_completion = ChatCompletion(
id=completion_id,
choices=[
Choice(
finish_reason=finish_reason,
index=0,
logprobs=None,
message=ChatCompletionMessage(
content=output_text,
role="assistant",
tool_calls=tool_calls,
),
)
],
created=current_time,
model=self.model_type or "unknown",
object="chat.completion",
service_tier=None,
system_fingerprint=None,
usage=CompletionUsage(
prompt_tokens=len(input_ids),
completion_tokens=len(output_token_ids),
total_tokens=len(input_ids) + len(output_token_ids),
),
)
interaction = Interaction(
turn_idx=turn_idx,
completion=deepcopy(chat_completion),
input_ids=list(input_ids),
output_token_ids=output_token_ids,
output_token_logprobs=output_token_logprobs,
output_text=raw_output_text,
finish_reason=finish_reason,
messages=deepcopy(messages),
latency_ms=latency_ms,
)
return chat_completion, interaction
def _apply_chat_template(
self,
messages: List[dict[str, Any]],
tools: List[dict[str, Any]] | None,
) -> List[int]:
if self.chat_template_type == "hf":
try:
return self.tokenizer.apply_chat_template(
messages,
tools=tools or None,
add_generation_prompt=True,
tokenize=True,
**self.chat_template_kwargs,
)
except Exception:
return self.tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
**self.chat_template_kwargs,
)
if self.chat_template_type == "concat":
start = self.messages_delimiter_start
end = self.messages_delimiter_end
message_strs: List[str] = []
for msg in messages:
message_strs.append(f"{start}{msg['role']}\n{msg['content']}{end}\n")
message_strs.append(f"{start}assistant\n")
return self.tokenizer.encode("".join(message_strs))
raise ValueError(f"Unsupported chat_template_type: {self.chat_template_type!r}")
+127
View File
@@ -0,0 +1,127 @@
# Remote worker (pool server)
This directory runs on the **remote worker**: a pool server that manages Docker containers and executes terminal tasks.
## Prerequisites
Set up a machine that will act as a worker node: a cloud VM (e.g. AWS EC2, GCP, or any provider), a bare-metal server, or any host where:
- You can install **Docker** (and Docker Compose).
- You have network connectivity so that the **training cluster** (where the router runs) can reach this host on the port you use for the pool server (default **18081**).
### GPU (optional)
A GPU is not required to run the pool server, but may be required by some tasks.
---
## Instructions
### 1. Clone the repo
From a directory of your choice:
```bash
git clone https://github.com/Gen-Verse/OpenClaw-RL.git
cd OpenClaw-RL
```
### 2. Install dependencies
Install Docker, a Python 3.12 environment, and the Python packages required by the pool server. You can use the provided script (run from **repo root**):
```bash
bash terminal-rl/remote/setup.sh
```
This will:
- Install Docker and Docker Compose if missing.
- Install [uv](https://github.com/astral-sh/uv) and create a virtualenv at repo root (`.venv`).
- Install other required packages.
### 3. Download dataset
To download a dataset:
```bash
source .venv/bin/activate
export DATASET_DIR="terminal-rl/dataset"
python terminal-rl/data_utils/download.py seta_env
```
The `seta_env` dataset corresponds to the task dataset published in: [camel-ai/seta-env](https://github.com/camel-ai/seta-env/tree/main/Dataset).
### 4. Run the pool server
From the **repo root**:
```bash
bash terminal-rl/remote/run_pool_server.sh
```
This script:
- Activates the venv if `.venv` exists.
- Sets `DATASET_DIR` and `TBENCH_OUTPUT_ROOT` under `terminal-rl/` by default.
- Starts the pool server with `python -m terminal-rl.remote.pool_server` on `0.0.0.0:18081` (overridable via `ENV_SERVER_PORT`, `WORKER_MAX_TASKS`, `WORKER_MAX_RUNS_PER_TASK`).
Run in background / under a process manager as needed. Example (nohup`):
```bash
nohup bash terminal-rl/remote/run_pool_server.sh > pool_server.log 2>&1 &
```
### 5. Tell the training side the worker URL
On the training machine (router host), set `WORKER_URLS` to include this worker:
```bash
export WORKER_URLS="http://<this-machine-ip-or-hostname>:18081"
```
For multiple workers, use a comma-separated list:
```bash
export WORKER_URLS="http://worker1:18081,http://worker2:18081"
```
Then start the router and training as described in the main Terminal RL docs; the router forwards requests to these pool servers.
---
## Optional environment variables
When running the pool server (via `run_pool_server.sh` or `python -m terminal-rl.remote.pool_server`), the following variables are supported:
| Variable | Default | Description |
| ---------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `DATASET_DIR` | `terminal-rl/dataset` | Path to the task dataset directory. |
| `TBENCH_OUTPUT_ROOT` | `terminal-rl/build_outputs` | Root directory for build/output artifacts. |
| `ENV_SERVER_PORT` | `18081` | Port the pool server listens on. |
| `WORKER_MAX_TASKS` | `16` | Max tasks allocate to per worker. |
| `WORKER_MAX_RUNS_PER_TASK` | `8` | Max concurrent runs per task. |
| `TBENCH_DOCKER_IMAGE_SOURCE` | `build` | `build` or `pull` — build images locally or pull from a registry. |
| `TBENCH_DOCKER_PULL_PREFIX` | — | Image name prefix used in `pull` mode; the task name is appended (e.g., `task-1374` → `<prefix>task-1374`). |
| `COMPOSE_OVERRIDE_PATH` | — | Optional Docker Compose override file. |
Example with custom port and limits:
```bash
export ENV_SERVER_PORT=18082
export WORKER_MAX_TASKS=10
export WORKER_MAX_RUNS_PER_TASK=8
bash terminal-rl/remote/run_pool_server.sh
```
Example using pre-built images from a registry (pull mode). Set the image source and prefix; you can build and push your own:
```bash
export TBENCH_DOCKER_IMAGE_SOURCE=pull
export TBENCH_DOCKER_PULL_PREFIX="ghcr.io/<your-org>/<your-image>:task-"
export COMPOSE_OVERRIDE_PATH="terminal-rl/remote/compose_override.yaml"
bash terminal-rl/remote/run_pool_server.sh
```
View File
+9
View File
@@ -0,0 +1,9 @@
services:
client:
environment:
- http_proxy=${http_proxy}
- https_proxy=${https_proxy}
- no_proxy=${no_proxy}
- HTTP_PROXY=${http_proxy}
- HTTPS_PROXY=${https_proxy}
- NO_PROXY=${no_proxy}
+374
View File
@@ -0,0 +1,374 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from terminal_bench.handlers.trial_handler import TrialHandler
from terminal_bench.terminal.docker_compose_manager import DockerComposeManager
from terminal_bench.terminal.terminal import Terminal
@dataclass(frozen=True)
class ImagePreparationResult:
mode: Literal["build", "pull"]
client_image_name: str | None = None
def _shorten_output(text: str | None, max_chars: int = 4000) -> str:
if not text:
return ""
stripped = text.strip()
if len(stripped) <= max_chars:
return stripped
return f"{stripped[:max_chars]}...(truncated, total={len(stripped)} chars)"
def _build_docker_pull_error_message(
*,
image: str,
cmd: list[str],
return_code: int | None = None,
timeout: float | None = None,
stdout: str | None = None,
stderr: str | None = None,
) -> str:
lines = [
f"Docker image pull failed for image '{image}'.",
f"Command: {' '.join(cmd)}",
]
if return_code is not None:
lines.append(f"Exit code: {return_code}")
if timeout is not None:
lines.append(f"Timeout: {timeout:.1f}s")
out = _shorten_output(stdout)
err = _shorten_output(stderr)
if out:
lines.append(f"STDOUT:\n{out}")
if err:
lines.append(f"STDERR:\n{err}")
lines.append(
"Hints: verify task_name/task image tag, run docker login for the registry, and ensure image exists."
)
return "\n".join(lines)
def _build_compose_up_error_message(
*,
cmd: list[str],
return_code: int | None = None,
timeout: float | None = None,
stdout: str | None = None,
stderr: str | None = None,
note: str | None = None,
) -> str:
lines = [
"Docker compose up --no-build failed.",
f"Command: {' '.join(cmd)}",
]
if return_code is not None:
lines.append(f"Exit code: {return_code}")
if timeout is not None:
lines.append(f"Timeout: {timeout:.1f}s")
out = _shorten_output(stdout)
err = _shorten_output(stderr)
if out:
lines.append(f"STDOUT:\n{out}")
if err:
lines.append(f"STDERR:\n{err}")
if note:
lines.append(f"Note: {note}")
lines.append(
"Hints: verify `docker compose version`; if unavailable, install Compose plugin or ensure `docker-compose` is on PATH."
)
return "\n".join(lines)
def _compose_plugin_maybe_missing(stderr: str | None) -> bool:
if not stderr:
return False
lowered = stderr.lower()
return (
"unknown shorthand flag: 'p' in -p" in lowered
or "docker: 'compose' is not a docker command" in lowered
or 'unknown command "compose"' in lowered
)
def build_docker_image(task: dict[str, Any], timeout: float = 1200.0) -> None:
dataset_dir = str(os.getenv("DATASET_DIR", "")).strip()
if not dataset_dir:
raise ValueError("DATASET_DIR is required")
task_path = Path(dataset_dir) / str(task.get("task_path", ""))
trial_handler = TrialHandler(
trial_name="build_run",
input_path=task_path,
output_path=Path("build_outputs"),
)
compose_manager = DockerComposeManager(
client_container_name=trial_handler.client_container_name,
client_image_name=trial_handler.client_image_name,
docker_image_name_prefix=trial_handler.docker_image_name_prefix,
docker_compose_path=trial_handler.task_paths.docker_compose_path,
no_rebuild=True,
cleanup=False,
sessions_logs_path=trial_handler.trial_paths.sessions_path,
agent_logs_path=trial_handler.trial_paths.agent_logging_dir,
)
compose_manager.build(timeout=timeout)
def _resolve_pull_image(task: dict[str, Any]) -> str:
prefix = str(os.getenv("TBENCH_DOCKER_PULL_PREFIX", "")).strip()
if not prefix:
raise ValueError("TBENCH_DOCKER_PULL_PREFIX is required in pull mode")
task_name = str(task.get("task_name", "")).strip()
if not task_name:
raise ValueError("task_name is required to resolve pull image")
if "<" in task_name and ">" in task_name:
raise ValueError(
"task_name appears to still be a placeholder "
f"('{task_name}'). Please provide a concrete task_name."
)
return f"{prefix}{task_name}"
def _docker_image_exists_locally(image: str, timeout: float = 30.0) -> bool:
cmd = ["docker", "image", "inspect", image]
result = subprocess.run(
cmd,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode == 0
def pull_docker_image(image: str, timeout: float = 1200.0) -> None:
if _docker_image_exists_locally(image):
return
cmd = ["docker", "pull", image]
try:
subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
_build_docker_pull_error_message(
image=image,
cmd=cmd,
timeout=timeout,
stdout=exc.stdout,
stderr=exc.stderr,
)
) from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(
_build_docker_pull_error_message(
image=image,
cmd=cmd,
return_code=exc.returncode,
stdout=exc.stdout,
stderr=exc.stderr,
)
) from exc
def prepare_task_docker_image(
task: dict[str, Any],
timeout: float = 1200.0,
) -> ImagePreparationResult:
raw_mode = str(os.getenv("TBENCH_DOCKER_IMAGE_SOURCE", "")).strip().lower()
if not raw_mode:
raise ValueError("TBENCH_DOCKER_IMAGE_SOURCE is required")
if raw_mode in {"build", "docker_build"}:
build_docker_image(task=task, timeout=timeout)
return ImagePreparationResult(mode="build", client_image_name=None)
if raw_mode in {"pull", "docker_pull"}:
image = _resolve_pull_image(task=task)
pull_docker_image(image=image, timeout=timeout)
return ImagePreparationResult(mode="pull", client_image_name=image)
raise ValueError(
f"Unsupported docker image source '{raw_mode}'. Expected one of: build, pull"
)
_DEFAULT_CONTAINER_MEMORY_LIMIT = os.getenv("CONTAINER_MEMORY_LIMIT", "16g")
def _apply_container_memory_limit(
container_name: str,
memory_limit: str,
logger: logging.Logger | None = None,
) -> None:
"""Best-effort ``docker update --memory`` on a running container."""
if not memory_limit:
return
cmd = [
"docker",
"update",
f"--memory={memory_limit}",
f"--memory-swap={memory_limit}",
container_name,
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=30.0)
if logger is not None:
logger.info(
"Applied memory limit %s to container %s", memory_limit, container_name
)
except Exception as exc:
if logger is not None:
logger.warning(
"Failed to apply memory limit to container %s: %s", container_name, exc
)
def compose_up_no_build(
terminal: Terminal,
*,
timeout: float,
container_name: str,
logger: logging.Logger | None = None,
) -> None:
compose_manager = getattr(terminal, "_compose_manager")
compose_override_path = str(os.getenv("COMPOSE_OVERRIDE_PATH", "")).strip()
compose_command = ["up", "-d", "--no-build"]
if compose_override_path:
compose_command = ["-f", compose_override_path, *compose_command]
command = compose_manager.get_docker_compose_command(compose_command)
if logger is not None:
logger.info("Running docker compose command: %s", " ".join(command))
if compose_override_path:
logger.info("Using compose override file: %s", compose_override_path)
compose_manager.env["http_proxy"] = os.getenv("HTTP_PROXY", "")
compose_manager.env["https_proxy"] = os.getenv("HTTPS_PROXY", "")
try:
subprocess.run(
command,
env=compose_manager.env,
check=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
if logger is not None:
logger.error(
"Docker compose up --no-build timed out after %.1f sec", timeout
)
if exc.stdout:
logger.error("STDOUT: %s", exc.stdout)
if exc.stderr:
logger.error("STDERR: %s", exc.stderr)
raise RuntimeError(
_build_compose_up_error_message(
cmd=command,
timeout=timeout,
stdout=exc.stdout,
stderr=exc.stderr,
)
) from exc
except subprocess.CalledProcessError as exc:
if logger is not None:
logger.error(
"Docker compose up --no-build failed with code %s", exc.returncode
)
if exc.stdout:
logger.error("STDOUT: %s", exc.stdout)
if exc.stderr:
logger.error("STDERR: %s", exc.stderr)
if _compose_plugin_maybe_missing(exc.stderr):
fallback_command = ["docker-compose", *command[2:]]
if shutil.which("docker-compose"):
if logger is not None:
logger.warning(
"docker compose plugin may be unavailable; falling back to: %s",
" ".join(fallback_command),
)
try:
subprocess.run(
fallback_command,
env=compose_manager.env,
check=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as fallback_exc:
raise RuntimeError(
_build_compose_up_error_message(
cmd=fallback_command,
timeout=timeout,
stdout=fallback_exc.stdout,
stderr=fallback_exc.stderr,
note=f"Initial command {' '.join(command)} failed with exit code {exc.returncode}.",
)
) from fallback_exc
except subprocess.CalledProcessError as fallback_exc:
raise RuntimeError(
_build_compose_up_error_message(
cmd=fallback_command,
return_code=fallback_exc.returncode,
stdout=fallback_exc.stdout,
stderr=fallback_exc.stderr,
note=f"Initial command {' '.join(command)} failed with exit code {exc.returncode}.",
)
) from fallback_exc
container = compose_manager._client.containers.get(container_name)
terminal.container = container
compose_manager._client_container = container
_apply_container_memory_limit(
container_name, _DEFAULT_CONTAINER_MEMORY_LIMIT, logger=logger
)
return
raise RuntimeError(
_build_compose_up_error_message(
cmd=command,
return_code=exc.returncode,
stdout=exc.stdout,
stderr=exc.stderr,
note="Compose plugin appears unavailable and `docker-compose` binary was not found.",
)
) from exc
raise RuntimeError(
_build_compose_up_error_message(
cmd=command,
return_code=exc.returncode,
stdout=exc.stdout,
stderr=exc.stderr,
)
) from exc
container = compose_manager._client.containers.get(container_name)
terminal.container = container
compose_manager._client_container = container
_apply_container_memory_limit(
container_name, _DEFAULT_CONTAINER_MEMORY_LIMIT, logger=logger
)
+679
View File
@@ -0,0 +1,679 @@
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from ..custom_types import RunContext, TaskSpec, TaskTimeouts
from ..request_utils import json_payload
from .terminal_env import TerminalEnv
logger = logging.getLogger("terminal.env.worker")
app = FastAPI()
def _parse_timeout_overrides(
base: TaskTimeouts, payload: dict[str, Any] | None
) -> TaskTimeouts:
if not isinstance(payload, dict):
return base
def _pick(key: str, default: float) -> float:
raw = payload.get(key, default)
try:
value = float(raw)
except (TypeError, ValueError):
return default
return value if value > 0 else default
return TaskTimeouts(
ensure_image=_pick("ensure_image", base.ensure_image),
reset_session=_pick("reset_session", base.reset_session),
close_session=_pick("close_session", base.close_session),
eval=_pick("eval", base.eval),
)
def _build_task_spec(task_meta: dict[str, Any]) -> TaskSpec:
return TaskSpec(
task_name=str(task_meta.get("task_name", "unknown")),
task_path=str(task_meta.get("task_path", "")),
instruction=str(task_meta.get("instruction", "")),
)
def _build_run_ctx(
run_ctx_payload: dict[str, Any] | None, default_log_dir: Path
) -> RunContext:
payload = run_ctx_payload if isinstance(run_ctx_payload, dict) else {}
uid = str(payload.get("uid") or uuid.uuid4().hex[:8])
try:
group_index = int(payload.get("group_index") or 0)
except (TypeError, ValueError):
group_index = 0
try:
sample_index = int(payload.get("sample_index") or 0)
except (TypeError, ValueError):
sample_index = 0
log_dir_raw = payload.get("log_dir")
if isinstance(log_dir_raw, str) and log_dir_raw:
log_dir = Path(log_dir_raw).resolve()
else:
log_dir = default_log_dir.resolve()
return RunContext(
uid=uid,
group_index=group_index,
sample_index=sample_index,
log_dir=log_dir,
)
class CapacityError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = message
super().__init__(message)
@dataclass
class RunSlot:
run_lease_id: str
task_key: str
env: TerminalEnv
last_used_ts: float = field(default_factory=time.time)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@dataclass
class TaskSlot:
task_key: str
runs: dict[str, RunSlot] = field(default_factory=dict)
created_ts: float = field(default_factory=time.time)
last_used_ts: float = field(default_factory=time.time)
class WorkerPool:
def __init__(
self,
*,
max_tasks: int,
max_runs_per_task: int,
run_idle_ttl: int,
output_root: str,
default_timeouts: TaskTimeouts,
idempotency_ttl: int = 300,
max_concurrent_closes: int = 8,
) -> None:
self.max_tasks = max_tasks
self.max_runs_per_task = max_runs_per_task
self.run_idle_ttl = run_idle_ttl
self.output_root = Path(output_root).resolve()
self.output_root.mkdir(parents=True, exist_ok=True)
self.default_timeouts = default_timeouts
self.idempotency_ttl = idempotency_ttl
self._tasks: dict[str, TaskSlot] = {}
self._run_to_task: dict[str, str] = {}
self._idempotency: dict[tuple[str, str], tuple[str, float]] = {}
self._lock = asyncio.Lock()
self._close_sem = asyncio.Semaphore(max_concurrent_closes)
self._closing_tasks: set[asyncio.Task] = set()
def _new_env(self) -> TerminalEnv:
return TerminalEnv()
async def _close_run_slot(
self, task_key: str, run_lease_id: str, run_slot: RunSlot, *, reason: str
) -> None:
logger.warning("%s %s (task=%s)", reason, run_lease_id, task_key)
async with self._close_sem:
async with run_slot.lock:
try:
await run_slot.env.close()
except Exception:
logger.exception("Failed to close run session %s", run_lease_id)
def _schedule_close(
self, task_key: str, run_lease_id: str, run_slot: RunSlot, *, reason: str
) -> None:
task = asyncio.create_task(
self._close_run_slot(task_key, run_lease_id, run_slot, reason=reason)
)
self._closing_tasks.add(task)
task.add_done_callback(self._closing_tasks.discard)
def _reap_idle_locked(self) -> list[tuple[str, str, RunSlot]]:
now = time.time()
expired_slots: list[tuple[str, str, RunSlot]] = []
expired_idem = [
k
for k, (_, ts) in self._idempotency.items()
if now - ts > self.idempotency_ttl
]
for k in expired_idem:
self._idempotency.pop(k, None)
for task_key, task_slot in list(self._tasks.items()):
expired_runs: list[str] = []
for rid, rslot in task_slot.runs.items():
if now - rslot.last_used_ts > self.run_idle_ttl:
expired_runs.append(rid)
for rid in expired_runs:
rslot = task_slot.runs.pop(rid, None)
self._run_to_task.pop(rid, None)
if rslot is not None:
expired_slots.append((task_key, rid, rslot))
if task_slot.runs:
task_slot.last_used_ts = max(
r.last_used_ts for r in task_slot.runs.values()
)
else:
logger.info("Reaping empty task slot: %s", task_key)
self._tasks.pop(task_key, None)
return expired_slots
def _get_run_slot(self, run_lease_id: str) -> RunSlot:
task_key = self._run_to_task.get(run_lease_id)
if task_key is None:
raise KeyError(f"Unknown run_lease_id: {run_lease_id}")
task_slot = self._tasks.get(task_key)
if task_slot is None:
raise KeyError(f"Run {run_lease_id} points to missing task slot")
run_slot = task_slot.runs.get(run_lease_id)
if run_slot is None:
raise KeyError(f"Run {run_lease_id} not found in task slot")
return run_slot
async def allocate(
self, task_key: str, request_id: str | None = None
) -> dict[str, Any]:
async with self._lock:
expired_slots = self._reap_idle_locked()
if request_id:
idem_key = (task_key, request_id)
cached = self._idempotency.get(idem_key)
if cached is not None:
run_lease_id, _ = cached
if run_lease_id in self._run_to_task:
return {"lease_id": run_lease_id, "reused": True}
task_slot = self._tasks.get(task_key)
if task_slot is None:
if len(self._tasks) >= self.max_tasks:
raise CapacityError(
"TASK_SLOTS_EXHAUSTED",
f"Worker at task capacity: {len(self._tasks)}/{self.max_tasks}",
)
task_slot = TaskSlot(task_key=task_key)
self._tasks[task_key] = task_slot
if len(task_slot.runs) >= self.max_runs_per_task:
raise CapacityError(
"RUN_SLOTS_EXHAUSTED",
f"Task {task_key} at run capacity: {len(task_slot.runs)}/{self.max_runs_per_task}",
)
env = self._new_env()
run_lease_id = f"run-{uuid.uuid4().hex[:16]}"
run_slot = RunSlot(run_lease_id=run_lease_id, task_key=task_key, env=env)
task_slot.runs[run_lease_id] = run_slot
task_slot.last_used_ts = time.time()
self._run_to_task[run_lease_id] = task_key
if request_id:
self._idempotency[(task_key, request_id)] = (run_lease_id, time.time())
for tk, rid, rslot in expired_slots:
self._schedule_close(tk, rid, rslot, reason="Reaping idle run slot")
return {"lease_id": run_lease_id, "reused": False}
async def heartbeat(self, run_lease_id: str) -> None:
async with self._lock:
run_slot = self._get_run_slot(run_lease_id)
async with run_slot.lock:
run_slot.last_used_ts = time.time()
async def reset(
self,
run_lease_id: str,
task_meta: dict[str, Any],
run_ctx_payload: dict[str, Any] | None = None,
task_timeouts: dict[str, Any] | None = None,
) -> dict[str, Any]:
if not isinstance(task_meta, dict):
raise ValueError("task_meta must be a dict")
async with self._lock:
run_slot = self._get_run_slot(run_lease_id)
run_ctx = _build_run_ctx(
run_ctx_payload, default_log_dir=self.output_root / "AgentRunner_Output"
)
timeouts = _parse_timeout_overrides(self.default_timeouts, task_timeouts)
task_spec = _build_task_spec(task_meta)
async with run_slot.lock:
user_msg, tool_schemas = await run_slot.env.reset(
task_meta=task_meta,
task_spec=task_spec,
run_ctx=run_ctx,
timeouts=timeouts,
)
run_slot.last_used_ts = time.time()
return {"user_msg": user_msg, "tool_schemas": tool_schemas}
async def exec_tool(
self, run_lease_id: str, tool_name: str, arguments: dict[str, Any] | None = None
) -> str:
async with self._lock:
run_slot = self._get_run_slot(run_lease_id)
async with run_slot.lock:
observation = await run_slot.env.exec_tool(tool_name, arguments or {})
run_slot.last_used_ts = time.time()
return str(observation)
async def evaluate(self, run_lease_id: str) -> float:
async with self._lock:
run_slot = self._get_run_slot(run_lease_id)
async with run_slot.lock:
score = await run_slot.env.evaluate()
run_slot.last_used_ts = time.time()
return float(score)
async def close_run(self, run_lease_id: str) -> bool:
async with self._lock:
task_key = self._run_to_task.pop(run_lease_id, None)
if task_key is None:
logger.debug(
"close_run: lease %s already gone, nothing to do.", run_lease_id
)
return False
task_slot = self._tasks.get(task_key)
run_slot = task_slot.runs.pop(run_lease_id, None) if task_slot else None
if task_slot is not None and not task_slot.runs:
self._tasks.pop(task_key, None)
logger.info("Removed empty task slot: %s", task_key)
if run_slot is not None:
self._schedule_close(
task_key, run_lease_id, run_slot, reason="Closing run slot"
)
return True
async def status(self) -> dict[str, Any]:
async with self._lock:
tasks_info: dict[str, Any] = {}
total_runs = 0
for tk, ts in self._tasks.items():
tasks_info[tk] = {"active_runs": len(ts.runs)}
total_runs += len(ts.runs)
return {
"max_tasks": self.max_tasks,
"active_tasks": len(self._tasks),
"max_runs_per_task": self.max_runs_per_task,
"total_active_runs": total_runs,
"pending_closes": len(self._closing_tasks),
"tasks": tasks_info,
}
async def periodic_reap(self, interval: float = 60.0) -> None:
while True:
await asyncio.sleep(interval)
try:
async with self._lock:
expired_slots = self._reap_idle_locked()
for tk, rid, rslot in expired_slots:
self._schedule_close(
tk, rid, rslot, reason="Periodic reaper: idle run slot"
)
if expired_slots:
logger.info(
"Periodic reaper cleaned up %d idle run slots",
len(expired_slots),
)
except Exception:
logger.exception("Periodic reaper error")
async def shutdown(self) -> None:
async with self._lock:
slots_to_close: list[tuple[str, str, RunSlot]] = []
for task_key, task_slot in self._tasks.items():
for run_lease_id, run_slot in task_slot.runs.items():
slots_to_close.append((task_key, run_lease_id, run_slot))
self._tasks.clear()
self._run_to_task.clear()
self._idempotency.clear()
for task_key, run_lease_id, run_slot in slots_to_close:
self._schedule_close(
task_key,
run_lease_id,
run_slot,
reason="Closing run slot during shutdown",
)
if self._closing_tasks:
logger.info(
"Shutdown: waiting for %d pending close tasks...",
len(self._closing_tasks),
)
await asyncio.gather(*self._closing_tasks, return_exceptions=True)
POOL: WorkerPool | None = None
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
return {"ok": True}
@app.get("/status")
async def status() -> JSONResponse:
if POOL is None:
return JSONResponse(
{"ok": False, "error": "Pool is not initialized"}, status_code=500
)
return JSONResponse({"ok": True, "pool": await POOL.status()})
@app.post("/allocate")
async def allocate(request: Request) -> JSONResponse:
if POOL is None:
return JSONResponse(
{"ok": False, "error": "Pool is not initialized"}, status_code=500
)
data = await json_payload(request)
task_key = data.get("task_key", "")
request_id = data.get("request_id")
if not task_key:
return JSONResponse(
{"ok": False, "error": "task_key is required"}, status_code=400
)
try:
result = await POOL.allocate(task_key=str(task_key), request_id=request_id)
return JSONResponse({"ok": True, **result})
except CapacityError as exc:
return JSONResponse(
{"ok": False, "error": exc.message, "code": exc.code}, status_code=429
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@app.post("/heartbeat")
async def heartbeat(request: Request) -> JSONResponse:
if POOL is None:
return JSONResponse(
{"ok": False, "error": "Pool is not initialized"}, status_code=500
)
data = await json_payload(request)
lease_id = data.get("lease_id")
if not lease_id:
return JSONResponse(
{"ok": False, "error": "lease_id is required"}, status_code=400
)
try:
await POOL.heartbeat(str(lease_id))
return JSONResponse({"ok": True})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@app.post("/reset")
async def reset(request: Request) -> JSONResponse:
if POOL is None:
return JSONResponse(
{"ok": False, "error": "Pool is not initialized"}, status_code=500
)
data = await json_payload(request)
lease_id = data.get("lease_id")
task_meta = data.get("task_meta")
run_ctx_payload = data.get("run_ctx")
task_timeouts = data.get("task_timeouts")
if not lease_id:
return JSONResponse(
{"ok": False, "error": "lease_id is required"}, status_code=400
)
if not isinstance(task_meta, dict):
return JSONResponse(
{"ok": False, "error": "task_meta dict is required"}, status_code=400
)
try:
out = await POOL.reset(
run_lease_id=str(lease_id),
task_meta=task_meta,
run_ctx_payload=run_ctx_payload,
task_timeouts=task_timeouts,
)
return JSONResponse({"ok": True, **out})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@app.post("/exec_tool")
async def exec_tool(request: Request) -> JSONResponse:
if POOL is None:
return JSONResponse(
{"ok": False, "error": "Pool is not initialized"}, status_code=500
)
data = await json_payload(request)
lease_id = data.get("lease_id")
tool_call = data.get("tool_call")
if not lease_id:
return JSONResponse(
{"ok": False, "error": "lease_id is required"}, status_code=400
)
if not isinstance(tool_call, dict):
return JSONResponse(
{"ok": False, "error": "tool_call dict is required"}, status_code=400
)
tool_name = tool_call.get("name")
arguments = tool_call.get("arguments")
if not isinstance(tool_name, str) or not tool_name:
return JSONResponse(
{"ok": False, "error": "tool_call.name is required"}, status_code=400
)
if arguments is not None and not isinstance(arguments, dict):
return JSONResponse(
{"ok": False, "error": "tool_call.arguments must be a dict"},
status_code=400,
)
try:
observation = await POOL.exec_tool(
str(lease_id), tool_name, arguments=arguments
)
return JSONResponse({"ok": True, "observation": observation})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@app.post("/evaluate")
async def evaluate(request: Request) -> JSONResponse:
if POOL is None:
return JSONResponse(
{"ok": False, "error": "Pool is not initialized"}, status_code=500
)
data = await json_payload(request)
lease_id = data.get("lease_id")
if not lease_id:
return JSONResponse(
{"ok": False, "error": "lease_id is required"}, status_code=400
)
try:
score = await POOL.evaluate(str(lease_id))
return JSONResponse({"ok": True, "score": score})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@app.post("/close")
async def close(request: Request) -> JSONResponse:
if POOL is None:
return JSONResponse(
{"ok": False, "error": "Pool is not initialized"}, status_code=500
)
data = await json_payload(request)
lease_id = data.get("lease_id")
if not lease_id:
return JSONResponse(
{"ok": False, "error": "lease_id is required"}, status_code=400
)
try:
found = await POOL.close_run(str(lease_id))
return JSONResponse({"ok": True, "found": found})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
_REAPER_TASK: asyncio.Task | None = None
@app.on_event("startup")
async def _on_startup() -> None:
global _REAPER_TASK
if POOL is not None:
_REAPER_TASK = asyncio.create_task(POOL.periodic_reap(interval=60.0))
@app.on_event("shutdown")
async def _on_shutdown() -> None:
global POOL, _REAPER_TASK
if _REAPER_TASK is not None:
_REAPER_TASK.cancel()
_REAPER_TASK = None
if POOL is not None:
await POOL.shutdown()
POOL = None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="C-layer: terminal env worker server")
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument(
"--port", type=int, default=int(os.getenv("ENV_SERVER_PORT", "18081"))
)
parser.add_argument(
"--max-tasks", type=int, default=int(os.getenv("WORKER_MAX_TASKS", "16"))
)
parser.add_argument(
"--max-runs-per-task",
type=int,
default=int(os.getenv("WORKER_MAX_RUNS_PER_TASK", "8")),
)
parser.add_argument(
"--run-idle-ttl",
type=int,
default=int(os.getenv("WORKER_RUN_IDLE_TTL", "600")),
help="Seconds before an idle RunSlot is reaped",
)
parser.add_argument(
"--output-root",
type=str,
default=os.getenv("TBENCH_OUTPUT_ROOT", "build_outputs"),
)
parser.add_argument(
"--ensure-image-timeout",
type=float,
default=float(os.getenv("ENSURE_IMAGE_TIMEOUT", "300.0")),
)
parser.add_argument(
"--reset-session-timeout",
type=float,
default=float(os.getenv("RESET_SESSION_TIMEOUT", "300.0")),
)
parser.add_argument(
"--close-session-timeout",
type=float,
default=float(os.getenv("CLOSE_SESSION_TIMEOUT", "60.0")),
)
parser.add_argument(
"--eval-timeout", type=float, default=float(os.getenv("EVAL_TIMEOUT", "600.0"))
)
parser.add_argument(
"--max-concurrent-closes",
type=int,
default=int(os.getenv("WORKER_MAX_CONCURRENT_CLOSES", "10")),
help="Max concurrent Docker stop operations",
)
return parser.parse_args()
def main() -> None:
global POOL
args = parse_args()
logging.basicConfig(
level=logging.INFO, format="[%(asctime)s %(levelname)s %(name)s] %(message)s"
)
POOL = WorkerPool(
max_tasks=args.max_tasks,
max_runs_per_task=args.max_runs_per_task,
run_idle_ttl=args.run_idle_ttl,
output_root=args.output_root,
default_timeouts=TaskTimeouts(
ensure_image=float(args.ensure_image_timeout),
reset_session=float(args.reset_session_timeout),
close_session=float(args.close_session_timeout),
eval=float(args.eval_timeout),
),
max_concurrent_closes=args.max_concurrent_closes,
)
logger.info(
"Starting worker server on %s:%s max_tasks=%s max_runs_per_task=%s",
args.host,
args.port,
args.max_tasks,
args.max_runs_per_task,
)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
TERMINAL_RL="$(cd -- "${SCRIPT_DIR}/.." &>/dev/null && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." &>/dev/null && pwd)"
cd "${REPO_ROOT}"
export DATASET_DIR="${DATASET_DIR:-${TERMINAL_RL}/dataset}"
export TBENCH_OUTPUT_ROOT="${TBENCH_OUTPUT_ROOT:-${TERMINAL_RL}/build_outputs}"
export TBENCH_DOCKER_IMAGE_SOURCE="${TBENCH_DOCKER_IMAGE_SOURCE:-build}"
export TBENCH_DOCKER_PULL_PREFIX="${TBENCH_DOCKER_PULL_PREFIX:-}"
export COMPOSE_OVERRIDE_PATH="${COMPOSE_OVERRIDE_PATH:-}"
if [ -d "${REPO_ROOT}/.venv" ]; then
source .venv/bin/activate
fi
# Start the pool server
exec python -m terminal-rl.remote.pool_server \
--host 0.0.0.0 \
--port "${ENV_SERVER_PORT:-18081}" \
--max-tasks "${WORKER_MAX_TASKS:-16}" \
--max-runs-per-task "${WORKER_MAX_RUNS_PER_TASK:-8}" \
--output-root "${TBENCH_OUTPUT_ROOT}"
+55
View File
@@ -0,0 +1,55 @@
# System dependencies
if ! command -v docker &> /dev/null; then
echo "Docker not found, installing..."
sudo apt-get update
sudo apt-get install -y docker.io
sudo apt-get install -y docker-compose-v2
sudo apt-get install -y docker-compose-plugin
sudo apt-get install -y docker-compose
else
echo "Docker found, skipping installation."
fi
# Modify docker to increase network address pool
DOCKER_DAEMON_CONFIG='/etc/docker/daemon.json'
# Backup existing daemon.json if it exists
if [ -f "$DOCKER_DAEMON_CONFIG" ]; then
echo "Backing up existing Docker daemon configuration..."
sudo cp "$DOCKER_DAEMON_CONFIG" "${DOCKER_DAEMON_CONFIG}.backup.$(date +%Y%m%d_%H%M%S)"
fi
# Create or update daemon.json with network pool settings
echo "Configuring Docker daemon..."
sudo tee "$DOCKER_DAEMON_CONFIG" > /dev/null <<EOF
{
"default-address-pools": [
{
"base": "10.200.0.0/16",
"size": 24
}
]
}
EOF
# Restart Docker to apply changes
echo "Restarting Docker daemon..."
sudo systemctl restart docker
echo "Docker configuration complete!"
# install uv if not found
if ! command -v uv &>/dev/null; then
echo "uv not found, installing..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="${HOME}/.local/bin:${PATH}"
else
echo "uv found, skipping installation."
fi
# Setup Python env
uv venv .venv --python 3.12
source .venv/bin/activate
uv pip install terminal-bench
uv pip install camel-ai
uv pip install fastapi
+335
View File
@@ -0,0 +1,335 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from functools import partial
from pathlib import Path
from typing import Any
from camel.toolkits import FunctionTool, TerminalToolkit
from terminal_bench.handlers.trial_handler import TrialHandler
from terminal_bench.parsers.base_parser import UnitTestStatus
from terminal_bench.parsers.parser_factory import ParserFactory
from terminal_bench.terminal.docker_compose_manager import DockerComposeManager
from terminal_bench.terminal.terminal import Terminal
from ..custom_types import RunContext, TaskSpec, TaskTimeouts
from .docker_compose_utils import compose_up_no_build, prepare_task_docker_image
logger = logging.getLogger(__name__)
def _stop_terminal_compat(terminal: Terminal, timeout: float) -> None:
try:
terminal.stop(timeout=timeout)
except TypeError as exc:
if "unexpected keyword argument 'timeout'" not in str(exc):
raise
logger.warning(
"Terminal.stop(timeout=...) is unsupported; retrying with Terminal.stop()."
)
terminal.stop()
def _drain_toolkit_sessions(toolkit: Any) -> None:
sessions = getattr(toolkit, "shell_sessions", None)
if not isinstance(sessions, dict):
return
lock = getattr(toolkit, "_session_lock", None)
try:
if lock is not None:
lock.acquire()
for session in sessions.values():
proc = session.get("process")
if proc is not None:
try:
if hasattr(proc, "terminate"):
proc.terminate()
elif hasattr(proc, "close"):
proc.close()
except Exception:
pass
q = session.get("output_stream")
if q is not None:
try:
while not q.empty():
q.get_nowait()
except Exception:
pass
sessions.clear()
finally:
if lock is not None:
try:
lock.release()
except RuntimeError:
pass
class TerminalEnv:
def __init__(self) -> None:
self._closed = False
self._task_spec: TaskSpec | None = None
self._run_ctx: RunContext | None = None
self._timeouts: TaskTimeouts | None = None
self._trial_handler: TrialHandler | None = None
self._terminal: Terminal | None = None
self._parser = None
self._terminal_toolkit: TerminalToolkit | None = None
self._tools: dict[str, Any] = {}
async def reset(
self,
*,
task_meta: dict[str, Any],
task_spec: TaskSpec,
run_ctx: RunContext,
timeouts: TaskTimeouts,
) -> tuple[str, list[dict[str, Any]]]:
await self.close()
self._closed = False
self._task_spec = task_spec
self._run_ctx = run_ctx
self._timeouts = timeouts
image_prep = await asyncio.to_thread(
prepare_task_docker_image,
task=task_meta,
timeout=self._timeouts.ensure_image,
)
dataset_dir = str(os.getenv("DATASET_DIR", "")).strip()
if not dataset_dir:
raise ValueError("DATASET_DIR is required")
task_path = Path(dataset_dir) / self._task_spec.task_path
output_path = Path(self._run_ctx.log_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
def _sync_reset() -> tuple[str, list[dict[str, Any]]]:
self._trial_handler = TrialHandler(
trial_name=f"{self._task_spec.task_name}.{self._run_ctx.uid}.slime-run",
input_path=task_path,
output_path=output_path,
)
task_config = self._trial_handler.task
self._parser = ParserFactory.get_parser(task_config.parser_name)
client_image_name = (
image_prep.client_image_name or self._trial_handler.client_image_name
)
self._terminal = Terminal(
client_container_name=self._trial_handler.client_container_name,
client_image_name=client_image_name,
docker_compose_path=self._trial_handler.task_paths.docker_compose_path,
docker_image_name_prefix=self._trial_handler.docker_image_name_prefix,
sessions_logs_path=self._trial_handler.trial_paths.sessions_path,
agent_logs_path=self._trial_handler.trial_paths.agent_logging_dir,
no_rebuild=True,
cleanup=False,
)
if image_prep.mode == "pull":
compose_up_no_build(
self._terminal,
timeout=self._timeouts.reset_session,
container_name=self._trial_handler.client_container_name,
logger=logger,
)
else:
self._terminal.start(timeout=self._timeouts.reset_session)
try:
from .docker_compose_utils import (
_DEFAULT_CONTAINER_MEMORY_LIMIT,
_apply_container_memory_limit,
)
_apply_container_memory_limit(
self._trial_handler.client_container_name,
_DEFAULT_CONTAINER_MEMORY_LIMIT,
logger=logger,
)
except Exception:
pass
session_logs_dir = (
self._trial_handler.trial_paths.sessions_path
/ "terminal_toolkit_session_logs"
)
self._terminal_toolkit = TerminalToolkit(
timeout=20.0,
working_directory=None,
use_docker_backend=True,
docker_container_name=self._trial_handler.client_container_name,
session_logs_dir=session_logs_dir,
safe_mode=False,
)
self._tools = {
"shell_exec": self._terminal_toolkit.shell_exec,
"shell_view": self._terminal_toolkit.shell_view,
"shell_write_to_process": self._terminal_toolkit.shell_write_to_process,
"shell_write_content_to_file": self._terminal_toolkit.shell_write_content_to_file,
}
user_msg = f"Task name:{self._task_spec.task_name}\nTask instruction: {self._task_spec.instruction}"
function_tools = [FunctionTool(fn) for fn in self._tools.values()]
tool_schemas = [
func_tool.get_openai_tool_schema() for func_tool in function_tools
]
return user_msg, tool_schemas
return await asyncio.to_thread(_sync_reset)
async def exec_tool(self, name: str, arguments: dict[str, Any]) -> str:
if not self._tools:
raise RuntimeError("env is not initialized; call reset first")
if name not in self._tools:
return f"[TOOL_ERROR] unknown tool: {name}"
fn = self._tools[name]
try:
if asyncio.iscoroutinefunction(fn):
result = await fn(**arguments)
elif hasattr(fn, "async_call") and callable(fn.async_call):
result = await fn.async_call(**arguments)
else:
result = await asyncio.to_thread(partial(fn, **arguments))
except Exception as exc:
return f"[TOOL_ERROR] {name}: {type(exc).__name__}: {exc}"
if isinstance(result, str):
return result
return json.dumps(result, ensure_ascii=False)
async def evaluate(self) -> float:
if (
self._trial_handler is None
or self._terminal is None
or self._parser is None
or self._timeouts is None
):
raise RuntimeError("env is not initialized; call reset first")
def _sync_eval() -> float:
task_name = (
self._task_spec.task_name if self._task_spec is not None else "unknown"
)
paths: list[Path] = [self._trial_handler.task_paths.run_tests_path]
if self._trial_handler.task_paths.test_dir.exists():
paths.append(self._trial_handler.task_paths.test_dir)
self._terminal.copy_to_container(
paths=paths,
container_dir=str(DockerComposeManager.CONTAINER_TEST_DIR),
)
test_session = self._terminal.create_session(
"tests",
is_active_stream=False,
as_configured_user=False,
)
test_script_path = str(
DockerComposeManager.CONTAINER_TEST_DIR / "run-tests.sh"
)
test_timeout_sec = min(
self._timeouts.eval,
4 * self._trial_handler.task.max_test_timeout_sec,
)
try:
test_session.send_keys(
[f"bash {test_script_path}", "Enter"],
block=True,
max_timeout_sec=test_timeout_sec,
)
except TimeoutError as exc:
logger.warning(
"Evaluation tests timed out for task=%s after %.1fs.",
task_name,
test_timeout_sec,
)
raise RuntimeError(
f"Evaluation tests timed out for task={task_name} after {test_timeout_sec:.1f}s"
) from exc
test_output = test_session.capture_pane(capture_entire=True)
try:
parser_results = self._parser.parse(test_output)
except Exception as exc:
tail = test_output[-2000:] if test_output else ""
logger.warning(
"Failed to parse test output for task=%s with parser=%s: %s. Output tail:\n%s",
task_name,
type(self._parser).__name__,
exc,
tail,
)
raise RuntimeError(
f"Failed to parse test output for task={task_name} with parser={type(self._parser).__name__}: {exc}"
) from exc
if not parser_results:
return 0.0
passed = sum(
1
for status in parser_results.values()
if status == UnitTestStatus.PASSED
)
reward = (
float(passed / len(parser_results)) if len(parser_results) > 0 else 0.0
)
return reward
return await asyncio.wait_for(
asyncio.to_thread(_sync_eval),
timeout=self._timeouts.eval + 30.0,
)
async def close(self) -> None:
trial_name = (
self._trial_handler.trial_name
if self._trial_handler is not None
else "unknown"
)
if self._closed:
logger.warning("TerminalEnv %s already closed", trial_name)
return
self._closed = True
terminal = self._terminal
timeouts = self._timeouts
toolkit = self._terminal_toolkit
self._tools = {}
self._terminal = None
self._trial_handler = None
self._parser = None
self._terminal_toolkit = None
self._task_spec = None
self._run_ctx = None
self._timeouts = None
if toolkit is not None:
try:
await asyncio.to_thread(toolkit.cleanup)
except Exception:
logger.exception(
"Failed to cleanup terminal toolkit for %s", trial_name
)
try:
await asyncio.to_thread(_drain_toolkit_sessions, toolkit)
except Exception:
logger.exception("Failed to drain toolkit sessions for %s", trial_name)
if terminal is not None and timeouts is not None:
try:
await asyncio.to_thread(
_stop_terminal_compat, terminal, timeouts.close_session
)
logger.info("TerminalEnv %s closed", trial_name)
except Exception:
logger.exception("Failed to stop terminal session during close")
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import Request
async def json_payload(request: Request) -> Dict[str, Any]:
"""Safely parse JSON body from a FastAPI request.
Returns an empty dict if parsing fails or the payload is not a JSON object.
"""
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import logging
from typing import Any, Dict, List
import wandb
from slime.utils import logging_utils
from slime.utils.types import Sample
from slime.ray.rollout import compute_rollout_step
logger = logging.getLogger(__name__)
def _ensure_terminal_step_metric(args) -> None:
if not getattr(args, "use_wandb", False):
return
try:
wandb.define_metric("terminal/*", step_metric="rollout/step")
except Exception as e:
logger.warning("Failed to define wandb step metric for terminal/*: %s", e)
def rollout_log(rollout_id, args, samples, rollout_extra_metrics, rollout_time):
trainable = [s for s in samples if not getattr(s, "remove_sample", False)]
non_trainable = [s for s in samples if getattr(s, "remove_sample", False)]
log_dict: Dict[str, Any] = {}
total = len(samples)
n_failed = sum(1 for s in samples if s.status == Sample.Status.FAILED)
n_aborted = sum(1 for s in samples if s.status == Sample.Status.ABORTED)
n_truncated = sum(1 for s in samples if s.status == Sample.Status.TRUNCATED)
n_completed = sum(1 for s in samples if s.status == Sample.Status.COMPLETED)
log_dict["terminal/total_samples"] = total
log_dict["terminal/completed"] = n_completed
log_dict["terminal/truncated"] = n_truncated
log_dict["terminal/failed"] = n_failed
log_dict["terminal/aborted"] = n_aborted
log_dict["terminal/failed_ratio"] = n_failed / total if total else 0.0
log_dict["terminal/non_trainable_ratio"] = (
len(non_trainable) / total if total else 0.0
)
if trainable:
trainable_rewards = [s.reward["score"] for s in trainable]
log_dict["terminal/reward_mean"] = sum(trainable_rewards) / len(
trainable_rewards
)
log_dict["terminal/reward_min"] = min(trainable_rewards)
log_dict["terminal/reward_max"] = max(trainable_rewards)
trainable_accs = []
for s in trainable:
if isinstance(s.reward, dict) and "accuracy" in s.reward:
trainable_accs.append(float(s.reward["accuracy"]))
if trainable_accs:
log_dict["terminal/accuracy"] = sum(trainable_accs) / len(trainable_accs)
trainable_prm = []
for s in trainable:
if isinstance(s.reward, dict) and "prm_turn_score" in s.reward:
trainable_prm.append(float(s.reward["prm_turn_score"]))
if trainable_prm:
log_dict["terminal/prm_turn_score"] = sum(trainable_prm) / len(
trainable_prm
)
log_dict["terminal/rollout_time"] = rollout_time
step = compute_rollout_step(args, rollout_id)
log_dict["rollout/step"] = step
_ensure_terminal_step_metric(args)
logging_utils.log(args, log_dict, step_key="rollout/step")
return False
+443
View File
@@ -0,0 +1,443 @@
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from hashlib import sha1
from typing import Any
import aiohttp
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from .request_utils import json_payload
logger = logging.getLogger("terminal.env.router")
app = FastAPI()
def _format_error(exc: BaseException) -> str:
detail = str(exc).strip()
if detail:
return f"{type(exc).__name__}: {detail}"
return type(exc).__name__
def _status_from_payload(payload: dict[str, Any], default: int) -> int:
raw = payload.get("status_code")
if isinstance(raw, int):
return raw
return default
class Router:
def __init__(
self,
worker_urls: list[str],
forward_timeout: float = 600.0,
forward_retries: int = 1,
forward_retry_backoff: float = 0.2,
):
if not worker_urls:
raise ValueError("At least one worker URL is required")
self.workers = [u.rstrip("/") for u in worker_urls]
self.forward_timeout = float(forward_timeout)
self.forward_retries = max(0, int(forward_retries))
self.forward_retry_backoff = max(0.0, float(forward_retry_backoff))
self._session: aiohttp.ClientSession | None = None
@property
def num_workers(self) -> int:
return len(self.workers)
async def startup(self) -> None:
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=self.forward_timeout)
connector = aiohttp.TCPConnector(limit=0, ttl_dns_cache=300)
self._session = aiohttp.ClientSession(timeout=timeout, connector=connector)
async def shutdown(self) -> None:
if self._session is not None and not self._session.closed:
await self._session.close()
self._session = None
def select_worker(self, task_key: str) -> tuple[int, str]:
digest = sha1(task_key.encode("utf-8")).digest()
idx = (
int.from_bytes(digest[:8], byteorder="big", signed=False) % self.num_workers
)
return idx, self.workers[idx]
@staticmethod
def encode_lease(worker_idx: int, worker_lease: str) -> str:
return f"{worker_idx}:{worker_lease}"
@staticmethod
def decode_lease(global_lease: str) -> tuple[int, str]:
sep = global_lease.index(":")
return int(global_lease[:sep]), global_lease[sep + 1 :]
def worker_url(self, worker_idx: int) -> str:
return self.workers[worker_idx]
def iter_worker_candidates(self, start_idx: int) -> list[tuple[int, str]]:
return [
(
(start_idx + offset) % self.num_workers,
self.workers[(start_idx + offset) % self.num_workers],
)
for offset in range(self.num_workers)
]
async def _request(
self,
method: str,
worker_url: str,
path: str,
payload: dict[str, Any] | None,
timeout: float | None,
) -> tuple[dict[str, Any], int]:
if self._session is None:
raise RuntimeError("Router HTTP session is not initialized")
kwargs: dict[str, Any] = {}
if payload is not None:
kwargs["json"] = payload
if timeout is not None:
kwargs["timeout"] = aiohttp.ClientTimeout(total=float(timeout))
max_attempts = self.forward_retries + 1
for attempt in range(1, max_attempts + 1):
try:
async with self._session.request(
method, f"{worker_url}{path}", **kwargs
) as resp:
status = resp.status
try:
body = await resp.json(content_type=None)
except Exception:
raw_text = await resp.text()
body = {
"ok": False,
"error": "Worker returned non-JSON response",
"raw_text": raw_text,
"status_code": status,
}
return body, status
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt >= max_attempts:
raise
logger.warning(
"Upstream request failed (%s %s) worker=%s attempt=%d/%d err=%s",
method,
path,
worker_url,
attempt,
max_attempts,
_format_error(exc),
)
backoff = self.forward_retry_backoff * attempt
if backoff > 0:
await asyncio.sleep(backoff)
async def forward(
self,
worker_url: str,
path: str,
payload: dict[str, Any],
timeout: float | None = None,
) -> tuple[dict[str, Any], int]:
return await self._request("POST", worker_url, path, payload, timeout)
async def forward_by_lease(
self,
global_lease: str,
path: str,
payload: dict[str, Any],
timeout: float | None = None,
) -> tuple[dict[str, Any], int]:
worker_idx, worker_lease = self.decode_lease(global_lease)
url = self.worker_url(worker_idx)
forwarded_payload = dict(payload)
forwarded_payload["lease_id"] = worker_lease
return await self.forward(url, path, forwarded_payload, timeout)
async def worker_status(
self, worker_url: str, timeout: float = 10.0
) -> tuple[dict[str, Any], int]:
return await self._request("GET", worker_url, "/status", None, timeout)
ROUTER: Router | None = None
def _worker_unreachable(
*,
worker_idx: int,
worker_url: str,
path: str,
exc: BaseException,
lease_id: str | None = None,
task_key: str | None = None,
) -> JSONResponse:
payload: dict[str, Any] = {
"ok": False,
"error": f"Worker unreachable: {_format_error(exc)}",
"worker_idx": worker_idx,
"worker_url": worker_url,
"path": path,
}
if lease_id:
payload["lease_id"] = lease_id
if task_key:
payload["task_key"] = task_key
return JSONResponse(payload, status_code=502)
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
return {"ok": True}
@app.get("/status")
async def status() -> JSONResponse:
if ROUTER is None:
return JSONResponse(
{"ok": False, "error": "Router is not initialized"}, status_code=500
)
async def _fetch(idx: int, url: str) -> dict[str, Any]:
try:
data, _ = await ROUTER.worker_status(url, timeout=10)
return {"worker_idx": idx, "url": url, **data}
except Exception as exc:
return {
"worker_idx": idx,
"url": url,
"ok": False,
"error": _format_error(exc),
}
workers = await asyncio.gather(
*[_fetch(idx, url) for idx, url in enumerate(ROUTER.workers)]
)
return JSONResponse(
{"ok": True, "num_workers": ROUTER.num_workers, "workers": workers}
)
@app.post("/allocate")
async def allocate(request: Request) -> JSONResponse:
if ROUTER is None:
return JSONResponse(
{"ok": False, "error": "Router is not initialized"}, status_code=500
)
data = await json_payload(request)
task_key = data.get("task_key", "")
request_id = data.get("request_id")
if not task_key:
return JSONResponse(
{"ok": False, "error": "task_key is required"}, status_code=400
)
try:
payload = {"task_key": task_key, "request_id": request_id}
primary_idx, _ = ROUTER.select_worker(str(task_key))
upstream_errors: list[dict[str, Any]] = []
for worker_idx, worker_url in ROUTER.iter_worker_candidates(primary_idx):
try:
result, code = await ROUTER.forward(worker_url, "/allocate", payload)
if worker_idx != primary_idx:
logger.warning(
"Primary worker unreachable for /allocate task_key=%s; fallback worker_idx=%d url=%s",
task_key,
worker_idx,
worker_url,
)
if result.get("ok") and "lease_id" in result:
result["lease_id"] = Router.encode_lease(
worker_idx, str(result["lease_id"])
)
result["worker_idx"] = worker_idx
return JSONResponse(
result, status_code=_status_from_payload(result, code)
)
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
logger.warning(
"Worker unreachable for /allocate task_key=%s worker_idx=%d url=%s err=%s",
task_key,
worker_idx,
worker_url,
_format_error(exc),
)
upstream_errors.append(
{
"worker_idx": worker_idx,
"worker_url": worker_url,
"detail": _format_error(exc),
}
)
return JSONResponse(
{
"ok": False,
"error": "Worker unreachable: all candidates failed for /allocate",
"task_key": task_key,
"primary_worker_idx": primary_idx,
"upstream_errors": upstream_errors,
},
status_code=502,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
async def _lease_proxy(path: str, request: Request) -> JSONResponse:
if ROUTER is None:
return JSONResponse(
{"ok": False, "error": "Router is not initialized"}, status_code=500
)
data = await json_payload(request)
global_lease = data.get("lease_id", "")
if not global_lease:
return JSONResponse(
{"ok": False, "error": "lease_id is required"}, status_code=400
)
try:
worker_idx, worker_lease = ROUTER.decode_lease(str(global_lease))
worker_url = ROUTER.worker_url(worker_idx)
except (ValueError, IndexError) as exc:
return JSONResponse(
{"ok": False, "error": f"Invalid lease_id format: {exc}"}, status_code=400
)
payload = dict(data)
payload["lease_id"] = worker_lease
try:
result, code = await ROUTER.forward(worker_url, path, payload)
return JSONResponse(result, status_code=_status_from_payload(result, code))
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
return _worker_unreachable(
worker_idx=worker_idx,
worker_url=worker_url,
path=path,
exc=exc,
lease_id=str(global_lease),
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@app.post("/heartbeat")
async def heartbeat(request: Request) -> JSONResponse:
return await _lease_proxy("/heartbeat", request)
@app.post("/reset")
async def reset(request: Request) -> JSONResponse:
return await _lease_proxy("/reset", request)
@app.post("/exec_tool")
async def exec_tool(request: Request) -> JSONResponse:
return await _lease_proxy("/exec_tool", request)
@app.post("/evaluate")
async def evaluate(request: Request) -> JSONResponse:
return await _lease_proxy("/evaluate", request)
@app.post("/close")
async def close(request: Request) -> JSONResponse:
return await _lease_proxy("/close", request)
@app.on_event("startup")
async def _on_startup() -> None:
if ROUTER is not None:
await ROUTER.startup()
@app.on_event("shutdown")
async def _on_shutdown() -> None:
if ROUTER is not None:
await ROUTER.shutdown()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="B-layer: terminal env router server")
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument(
"--port", type=int, default=int(os.getenv("ROUTER_PORT", "18080"))
)
parser.add_argument(
"--workers",
type=str,
default=os.getenv("WORKER_URLS", ""),
help="Comma-separated worker URLs, e.g. http://w0:18081,http://w1:18081",
)
parser.add_argument(
"--forward-timeout",
type=float,
default=float(os.getenv("ROUTER_FORWARD_TIMEOUT", "600.0")),
help="HTTP timeout (seconds) when forwarding to a worker",
)
parser.add_argument(
"--forward-retries",
type=int,
default=int(os.getenv("ROUTER_FORWARD_RETRIES", "1")),
help="Retries for transient worker connection errors",
)
parser.add_argument(
"--forward-retry-backoff",
type=float,
default=float(os.getenv("ROUTER_FORWARD_RETRY_BACKOFF", "0.2")),
help="Linear backoff (seconds) between worker retries",
)
return parser.parse_args()
def main() -> None:
global ROUTER
args = parse_args()
logging.basicConfig(
level=logging.INFO, format="[%(asctime)s %(levelname)s %(name)s] %(message)s"
)
worker_urls = [u.strip() for u in args.workers.split(",") if u.strip()]
if not worker_urls:
raise SystemExit(
"ERROR: --workers (or WORKER_URLS env) must list at least one worker URL"
)
ROUTER = Router(
worker_urls=worker_urls,
forward_timeout=args.forward_timeout,
forward_retries=args.forward_retries,
forward_retry_backoff=args.forward_retry_backoff,
)
logger.info(
"Starting router on %s:%s workers=%s forward_timeout=%s forward_retries=%s forward_retry_backoff=%s",
args.host,
args.port,
worker_urls,
args.forward_timeout,
args.forward_retries,
args.forward_retry_backoff,
)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env bash
set -euo pipefail
set -x
log() { echo "[$(date +'%F %T')] $*"; }
require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "[ERROR] missing cmd: $1"; exit 1; }; }
export PYTHONUNBUFFERED=1
export PYTHONFAULTHANDLER=1
export RAY_health_check_failure_threshold=${RAY_health_check_failure_threshold:-20}
export RAY_health_check_period_ms=${RAY_health_check_period_ms:-5000}
export RAY_health_check_timeout_ms=${RAY_health_check_timeout_ms:-30000}
export RAY_num_heartbeats_timeout=${RAY_num_heartbeats_timeout:-60}
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "[ERROR] missing env: ${name}"
exit 1
fi
}
NUM_NODES=${NUM_NODES:-2}
NUM_GPUS_PER_NODE=${NUM_GPUS_PER_NODE:-8}
ACTOR_NUM_NODES=${ACTOR_NUM_NODES:-1}
ACTOR_GPUS_PER_NODE=${ACTOR_GPUS_PER_NODE:-4}
ROLLOUT_GPUS_TOTAL=${ROLLOUT_GPUS_TOTAL:-4}
ROLLOUT_NUM_GPUS_PER_ENGINE=${ROLLOUT_NUM_GPUS_PER_ENGINE:-2}
PRM_GPUS_TOTAL=${PRM_GPUS_TOTAL:-8}
PRM_GPUS_PER_ENGINE=${PRM_GPUS_PER_ENGINE:-1}
PRM_ENABLE="${PRM_ENABLE:-1}"
PRM_MODEL_PATH="${PRM_MODEL_PATH:-}"
PRM_TEMPERATURE="${PRM_TEMPERATURE:-0.0}"
PRM_MAX_NEW_TOKENS="${PRM_MAX_NEW_TOKENS:-4096}"
PRM_M="${PRM_M:-1}"
PRM_STEP_COEF="${PRM_STEP_COEF:-1.0}"
PRM_SGLANG_URL="${PRM_SGLANG_URL:-}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
REPO_ROOT="${REPO_ROOT:-$(cd "${SCRIPT_DIR}/.." && pwd)}"
export REPO_ROOT
SLIME_DIR="${SLIME_DIR:-${REPO_ROOT}/slime}"
MEGATRON_LM_PATH="${MEGATRON_LM_PATH:-${REPO_ROOT}/Megatron-LM}"
export SLIME_PKG_DIR="${REPO_ROOT}/slime"
export MEGATRON_DIR="${REPO_ROOT}/Megatron-LM"
source "${SLIME_DIR}/scripts/models/qwen3-8B.sh"
# Paths: set/export before running (no built-in defaults).
HF_HOME="${HF_HOME:-}"
HF_CKPT="${HF_CKPT:-}"
REF_LOAD="${REF_LOAD:-}"
SAVE_CKPT="${SAVE_CKPT:-}"
RESUME_LOAD="${RESUME_LOAD:-${SAVE_CKPT}}"
ROLLOUT_PROMPT_DATA="${ROLLOUT_PROMPT_DATA:-}"
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-max_split_size_mb:2048,expandable_segments:True}"
MLP_ROLE_INDEX=${MLP_ROLE_INDEX:-0}
HEAD_ADDR="${MLP_WORKER_0_HOST:-${MASTER_ADDR:-$(hostname -I | awk '{print $1}')}}"
_WORKER_IP_VAR="MLP_WORKER_${MLP_ROLE_INDEX}_HOST"
NODE_IP="${!_WORKER_IP_VAR:-${WORKER_IP:-$(hostname -I | awk '{print $1}')}}"
unset MASTER_ADDR
export no_proxy="127.0.0.1,${HEAD_ADDR}"
log "MLP_ROLE_INDEX=${MLP_ROLE_INDEX}, HEAD_ADDR=${HEAD_ADDR}, NODE_IP=${NODE_IP}"
export USE_REMOTE_ENV="${USE_REMOTE_ENV:-1}"
export PROVIDER_NAME="${PROVIDER_NAME:-pull}"
export ENV_SERVER_BIND_HOST="${ENV_SERVER_BIND_HOST:-0.0.0.0}"
export ENV_SERVER_PORT="${ENV_SERVER_PORT:-18080}"
export ENV_SERVER_HOST="${ENV_SERVER_HOST:-${HEAD_ADDR}}"
export ENV_SERVER_URL="${ENV_SERVER_URL:-}"
export START_ENV_POOL_SERVER="${START_ENV_POOL_SERVER:-0}"
export RAY_TMPDIR=/tmp/ray_${MLP_ROLE_INDEX}
export WORKER_URLS="${WORKER_URLS:-}"
ROUTER_SESSION_NAME="${ROUTER_SESSION_NAME:-terminal_router}"
ROUTER_CONDA_ENV_PATH="${ROUTER_CONDA_ENV_PATH:-}"
ROUTER_PROJECT_DIR="${ROUTER_PROJECT_DIR:-${REPO_ROOT}}"
ROUTER_HOST="${ROUTER_HOST:-0.0.0.0}"
ROUTER_PORT="${ROUTER_PORT:-${ENV_SERVER_PORT}}"
CHECK_HOST="${CHECK_HOST:-127.0.0.1}"
CHECK_WAIT_SECS="${CHECK_WAIT_SECS:-60}"
ROUTER_RESTART="${ROUTER_RESTART:-1}"
CKPT_ARGS=(
--hf-checkpoint "${HF_CKPT}"
--ref-load "${REF_LOAD}"
--load "${RESUME_LOAD}"
--save "${SAVE_CKPT}"
--save-interval 3
--rotary-base 1000000
)
ROLLOUT_ARGS=(
--prompt-data "${ROLLOUT_PROMPT_DATA}"
--input-key task
--rollout-shuffle
--reward-key score
--num-rollout 2000
--rollout-batch-size 16
--n-samples-per-prompt 8
--rollout-max-response-len 8192
--rollout-max-context-len 16384
--rollout-temperature 1
--num-steps-per-rollout 2
--balance-data
)
EVAL_ARGS=(
--n-samples-per-eval-prompt 16
--eval-max-response-len 16384
--eval-top-p 1
)
PERF_ARGS=(
--tensor-model-parallel-size 4
--sequence-parallel
--pipeline-model-parallel-size 1
--context-parallel-size 1
--expert-model-parallel-size 1
--expert-tensor-parallel-size 1
--recompute-granularity full
--recompute-method uniform
--recompute-num-layers 1
--use-dynamic-batch-size
--max-tokens-per-gpu 16384
--log-probs-chunk-size 1024
)
GRPO_ARGS=(
--advantage-estimator step_wise
--dynamic_history
--use-kl-loss
--kl-loss-coef 0.01
--kl-loss-type k3
)
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
)
WANDB_KEY_VALUE=${WANDB_KEY:-${WANDB_API_KEY:-}}
if [ -n "${WANDB_KEY_VALUE}" ]; then
WANDB_ARGS=(
--use-wandb
--wandb-project slime
--wandb-group qwen3-8B-prm-2nodes-rl_terminal
--wandb-key ${WANDB_KEY_VALUE}
)
else
WANDB_ARGS=()
fi
SGLANG_ARGS=(
--rollout-num-gpus-per-engine ${ROLLOUT_NUM_GPUS_PER_ENGINE}
--sglang-mem-fraction-static 0.6
)
MISC_ARGS=(
--attention-dropout 0.0
--hidden-dropout 0.0
--accumulate-allreduce-grads-in-fp32
--attention-softmax-in-fp32
--attention-backend flash
)
CUSTOM_ARGS=(
--custom-generate-function-path generate.generate
--custom-rollout-log-function-path rollout_log.rollout_log
)
PRM_ARGS=(
--prm-m "${PRM_M}"
--prm-temperature "${PRM_TEMPERATURE}"
--prm-max-new-tokens "${PRM_MAX_NEW_TOKENS}"
--prm-num-gpus "${PRM_GPUS_TOTAL}"
--prm-num-gpus-per-engine "${PRM_GPUS_PER_ENGINE}"
--prm-step-coef "${PRM_STEP_COEF}"
)
if [[ "${PRM_ENABLE:-0}" == "1" ]]; then
PRM_ARGS+=(--prm-enable)
PRM_ARGS+=(--prm-model-path "${PRM_MODEL_PATH}")
fi
if [[ -n "${PRM_SGLANG_URL}" ]]; then
PRM_ARGS+=(--prm-sglang-url "${PRM_SGLANG_URL}")
fi
check_gpus() {
local total_available=$((NUM_NODES * NUM_GPUS_PER_NODE))
local total_requested=$((ACTOR_NUM_NODES * ACTOR_GPUS_PER_NODE + ROLLOUT_GPUS_TOTAL + PRM_GPUS_TOTAL))
if (( total_requested > total_available )); then
echo "Requested GPUs exceed cluster capacity."
echo "requested=${total_requested}, available=${total_available}"
echo "actor=$((ACTOR_NUM_NODES * ACTOR_GPUS_PER_NODE)), rollout=${ROLLOUT_GPUS_TOTAL}, prm=${PRM_GPUS_TOTAL}"
exit 1
fi
if [[ "${PRM_ENABLE:-0}" == "1" ]] && (( PRM_GPUS_TOTAL <= 0 )); then
echo "PRM_ENABLE=1 but PRM_GPUS_TOTAL=${PRM_GPUS_TOTAL} <= 0"
exit 1
fi
}
cleanup_prev() {
log "cleanup previous processes"
pkill -9 sglang || 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
}
start_router() {
if [[ ${MLP_ROLE_INDEX} -ne 0 ]]; then
log "worker node ${MLP_ROLE_INDEX}, skip start_router"
return 0
fi
require_cmd curl
mkdir -p "${ROUTER_PROJECT_DIR}/logs"
local logf="${ROUTER_PROJECT_DIR}/logs/router_${ROUTER_PORT}_2nodes.log"
"${ROUTER_CONDA_ENV_PATH}/bin/python" -m terminal-rl.router_server \
--host "${ROUTER_HOST}" --port "${ROUTER_PORT}" --workers "${WORKER_URLS}" \
> "${logf}" 2>&1 &
export ROUTER_PID=$!
log "router started pid=${ROUTER_PID}, log=${logf}"
trap 'set +e; kill "${ROUTER_PID}" 2>/dev/null || true' EXIT INT TERM
sleep 1
tail -n 50 "${logf}" || true
}
check_router() {
if [[ ${MLP_ROLE_INDEX} -ne 0 ]]; then
log "worker node ${MLP_ROLE_INDEX}, skip check_router"
return 0
fi
require_cmd curl
local base_url="http://${CHECK_HOST}:${ROUTER_PORT}"
log "wait router healthz up to ${CHECK_WAIT_SECS}s: ${base_url}/healthz"
for ((i=1; i<=CHECK_WAIT_SECS; i++)); do
if curl -fsS "${base_url}/healthz" >/dev/null 2>&1; then
log "router is up"
break
fi
sleep 1
done
log "curl ${base_url}/status"
curl -sS "${base_url}/status"
echo
log "curl ${base_url}/healthz"
curl -sS "${base_url}/healthz"
echo
}
detect_nvlink() {
local count
count="$(nvidia-smi topo -m 2>/dev/null | grep -o 'NV[0-9][0-9]*' | wc -l || true)"
if [[ "${count:-0}" -gt 0 ]]; then
export HAS_NVLINK=1
else
export HAS_NVLINK=0
fi
log "HAS_NVLINK=${HAS_NVLINK} (detected ${count} NVLink references)"
}
maybe_fill_env_server_url() {
if [[ "${USE_REMOTE_ENV}" == "1" && -z "${ENV_SERVER_URL}" ]]; then
export ENV_SERVER_URL="http://${ENV_SERVER_HOST}:${ENV_SERVER_PORT}"
if [[ "${START_ENV_POOL_SERVER}" == "0" ]]; then
export START_ENV_POOL_SERVER=1
fi
fi
log "ENV_SERVER_URL=${ENV_SERVER_URL} START_ENV_POOL_SERVER=${START_ENV_POOL_SERVER}"
}
start_ray_head() {
require_cmd ray
mkdir -p "${RAY_TMPDIR}"
if [[ ${MLP_ROLE_INDEX} -eq 0 ]]; then
log "start ray head on node 0"
ray start --head \
--node-ip-address "${NODE_IP}" \
--num-gpus "${NUM_GPUS_PER_NODE}" \
--disable-usage-stats \
--dashboard-host=0.0.0.0 \
--dashboard-port=8265 \
--temp-dir "${RAY_TMPDIR}"
else
log "worker node ${MLP_ROLE_INDEX}: wait 30s then join ray cluster at ${HEAD_ADDR}:6379"
sleep 30
ray start \
--address="${HEAD_ADDR}:6379" \
--num-gpus "${NUM_GPUS_PER_NODE}" \
--node-ip-address "${NODE_IP}" \
--temp-dir "${RAY_TMPDIR}"
fi
}
build_runtime_env_json() {
python3 - <<'PY'
import json, os
parts = [
os.environ.get("REPO_ROOT",""),
os.environ.get("SLIME_PKG_DIR",""),
os.environ.get("MEGATRON_DIR",""),
os.environ.get("SCRIPT_DIR",""),
]
pythonpath = ":".join([p for p in parts if p])
env_vars = {
"PYTHONPATH": pythonpath,
"CUDA_DEVICE_MAX_CONNECTIONS": "1",
"NCCL_NVLS_ENABLE": os.environ.get("HAS_NVLINK","0"),
"PYTORCH_CUDA_ALLOC_CONF": os.environ.get("PYTORCH_CUDA_ALLOC_CONF",""),
"USE_REMOTE_ENV": os.environ.get("USE_REMOTE_ENV","0"),
"ENV_SERVER_URL": os.environ.get("ENV_SERVER_URL",""),
"PRM_SGLANG_URL": os.environ.get("PRM_SGLANG_URL",""),
}
print(json.dumps({"env_vars": env_vars}))
PY
}
submit_job() {
require_env HF_CKPT
require_env REF_LOAD
require_env SAVE_CKPT
require_env ROLLOUT_PROMPT_DATA
if [[ "${PRM_ENABLE:-0}" == "1" ]]; then
require_env PRM_MODEL_PATH
fi
if [[ ${MLP_ROLE_INDEX} -eq 0 ]]; then
log "submit ray job (head node)"
local runtime_env_json
runtime_env_json="$(build_runtime_env_json)"
local submission_id="${RAY_JOB_SUBMISSION_ID:-terminal_qwen3_8b_prm_2nodes_$(date +%Y%m%d_%H%M%S)}"
ray job submit --address="http://${HEAD_ADDR}:8265" \
--submission-id "${submission_id}" \
--no-wait \
--runtime-env-json="${runtime_env_json}" \
-- python3 -u ${SLIME_DIR}/train_async.py \
--actor-num-nodes "${ACTOR_NUM_NODES}" \
--actor-num-gpus-per-node "${ACTOR_GPUS_PER_NODE}" \
--rollout-num-gpus "${ROLLOUT_GPUS_TOTAL}" \
"${MODEL_ARGS[@]}" \
"${CKPT_ARGS[@]}" \
"${ROLLOUT_ARGS[@]}" \
"${OPTIMIZER_ARGS[@]}" \
"${GRPO_ARGS[@]}" \
"${WANDB_ARGS[@]}" \
"${PERF_ARGS[@]}" \
"${EVAL_ARGS[@]}" \
"${SGLANG_ARGS[@]}" \
"${MISC_ARGS[@]}" \
"${CUSTOM_ARGS[@]}" \
"${PRM_ARGS[@]}"
log "Following live Ray logs for ${submission_id}"
set +e
ray job logs --address="http://${HEAD_ADDR}:8265" "${submission_id}" -f --log-style=record
local ray_log_exit=$?
local ray_status
ray_status=$(ray job status --address="http://${HEAD_ADDR}:8265" "${submission_id}" --log-style=record 2>&1)
echo "${ray_status}"
set -e
if [[ "${ray_status}" == *"SUCCEEDED"* ]]; then
exit 0
fi
echo "Ray job failed (submission id: ${submission_id}, logs exit: ${ray_log_exit})"
exit 1
else
log "Worker node ${MLP_ROLE_INDEX} joined the cluster. Waiting for job to finish..."
while ray status > /dev/null 2>&1; do
sleep 60
done
log "Ray cluster stopped. Worker node ${MLP_ROLE_INDEX} exiting."
fi
}
cleanup_prev
start_router
check_router
check_gpus
detect_nvlink
maybe_fill_env_server_url
export SCRIPT_DIR
start_ray_head
submit_job
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
set -euo pipefail
set -x
log() { echo "[$(date +'%F %T')] $*"; }
require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "[ERROR] missing cmd: $1"; exit 1; }; }
export PYTHONBUFFERED=16
NUM_GPUS="${NUM_GPUS:-8}"
ACTOR_GPUS="${ACTOR_GPUS:-4}"
ROLLOUT_GPUS="${ROLLOUT_GPUS:-4}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
REPO_ROOT="${REPO_ROOT:-$(cd "${SCRIPT_DIR}/.." && pwd)}"
export REPO_ROOT
export SLIME_DIR="${REPO_ROOT}/slime"
export MEGATRON_DIR="${MEGATRON_DIR:-${REPO_ROOT}/Megatron-LM}"
source "${SLIME_DIR}/scripts/models/qwen3-8B.sh"
# Paths: set/export before running (no built-in defaults).
HF_HOME="${HF_HOME:-}"
HF_CKPT="${HF_CKPT:-}"
REF_LOAD="${REF_LOAD:-}"
SAVE_CKPT="${SAVE_CKPT:-}"
RESUME_LOAD="${RESUME_LOAD:-${SAVE_CKPT}}"
ROLLOUT_PROMPT_DATA="${ROLLOUT_PROMPT_DATA:-}"
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-max_split_size_mb:2048,expandable_segments:True}"
export MASTER_ADDR="${MASTER_ADDR:-127.0.0.1}"
export USE_REMOTE_ENV="${USE_REMOTE_ENV:-1}"
export PROVIDER_NAME="${PROVIDER_NAME:-pull}"
export ENV_SERVER_BIND_HOST="${ENV_SERVER_BIND_HOST:-0.0.0.0}"
export ENV_SERVER_PORT="${ENV_SERVER_PORT:-18080}"
export ENV_SERVER_HOST="${ENV_SERVER_HOST:-${MASTER_ADDR}}"
export ENV_SERVER_URL="${ENV_SERVER_URL:-}"
export START_ENV_POOL_SERVER="${START_ENV_POOL_SERVER:-0}"
export RAY_TMPDIR="${RAY_TMPDIR:-}"
export WORKER_URLS="${WORKER_URLS:-}"
ROUTER_SESSION_NAME="${ROUTER_SESSION_NAME:-terminal_router}"
ROUTER_CONDA_ENV_PATH="${ROUTER_CONDA_ENV_PATH:-}"
ROUTER_PROJECT_DIR="${ROUTER_PROJECT_DIR:-${REPO_ROOT}}"
export ROUTER_CONDA_ENV_PATH
CONDA_PYTHON_VERSION="${CONDA_PYTHON_VERSION:-3.12}"
export CONDA_PYTHON_VERSION
ROUTER_HOST="${ROUTER_HOST:-0.0.0.0}"
ROUTER_PORT="${ROUTER_PORT:-${ENV_SERVER_PORT}}"
CHECK_HOST="${CHECK_HOST:-127.0.0.1}"
CHECK_WAIT_SECS="${CHECK_WAIT_SECS:-60}"
ROUTER_RESTART="${ROUTER_RESTART:-1}"
CKPT_ARGS=(
--hf-checkpoint "${HF_CKPT}"
--ref-load "${REF_LOAD}"
--load "${RESUME_LOAD}"
--save "${SAVE_CKPT}"
--save-interval 3
--rotary-base 1000000
)
ROLLOUT_ARGS=(
--prompt-data "${ROLLOUT_PROMPT_DATA}"
--input-key task
--rollout-shuffle
--reward-key score
--num-rollout 2000
--rollout-batch-size 16
--n-samples-per-prompt 8
--rollout-max-response-len 8192
--rollout-max-context-len 16384
--rollout-temperature 1
--num-steps-per-rollout 2
--balance-data
)
EVAL_ARGS=(
--n-samples-per-eval-prompt 16
--eval-max-response-len 16384
--eval-top-p 1
)
PERF_ARGS=(
--tensor-model-parallel-size 4
--sequence-parallel
--pipeline-model-parallel-size 1
--context-parallel-size 1
--expert-model-parallel-size 1
--expert-tensor-parallel-size 1
--recompute-granularity full
--recompute-method uniform
--recompute-num-layers 1
--use-dynamic-batch-size
--max-tokens-per-gpu 16384
--log-probs-chunk-size 1024
)
GRPO_ARGS=(
--advantage-estimator grpo
--dynamic_history
--use-kl-loss
--kl-loss-coef 0.01
--kl-loss-type k3
)
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
)
WANDB_ARGS=(
--use-wandb
--wandb-project slime
--wandb-group qwen3-8B-rl_terminal
--wandb-key ${WANDB_KEY}
)
SGLANG_ARGS=(
--rollout-num-gpus-per-engine 2
--sglang-mem-fraction-static 0.6
)
MISC_ARGS=(
--attention-dropout 0.0
--hidden-dropout 0.0
--accumulate-allreduce-grads-in-fp32
--attention-softmax-in-fp32
--attention-backend flash
)
CUSTOM_ARGS=(
--custom-generate-function-path generate.generate
--custom-rollout-log-function-path rollout_log.rollout_log
)
check_gpus() {
if (( ACTOR_GPUS + ROLLOUT_GPUS > NUM_GPUS )); then
echo "ACTOR_GPUS + ROLLOUT_GPUS must be <= NUM_GPUS"
echo "ACTOR_GPUS=${ACTOR_GPUS}, ROLLOUT_GPUS=${ROLLOUT_GPUS}, NUM_GPUS=${NUM_GPUS}"
exit 1
fi
}
cleanup_prev() {
log "cleanup previous processes"
pkill -9 sglang || 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
}
start_router() {
require_cmd curl
mkdir -p "${ROUTER_PROJECT_DIR}/logs"
local logf="${ROUTER_PROJECT_DIR}/logs/router_${ROUTER_PORT}.log"
"${ROUTER_CONDA_ENV_PATH}/bin/python" -m terminal-rl.router_server \
--host "${ROUTER_HOST}" --port "${ROUTER_PORT}" --workers "${WORKER_URLS}" \
> "${logf}" 2>&1 &
export ROUTER_PID=$!
log "router started pid=${ROUTER_PID}, log=${logf}"
sleep 1
tail -n 50 "${logf}" || true
}
check_router() {
require_cmd curl
local base_url="http://${CHECK_HOST}:${ROUTER_PORT}"
log "wait router healthz up to ${CHECK_WAIT_SECS}s: ${base_url}/healthz"
for ((i=1; i<=CHECK_WAIT_SECS; i++)); do
if curl -fsS "${base_url}/healthz" >/dev/null 2>&1; then
log "router is up"
break
fi
sleep 1
done
log "curl ${base_url}/status"
curl -sS "${base_url}/status"
echo
log "curl ${base_url}/healthz"
curl -sS "${base_url}/healthz"
echo
}
detect_nvlink() {
local count
count="$(nvidia-smi topo -m 2>/dev/null | grep -o 'NV[0-9][0-9]*' | wc -l || true)"
if [[ "${count:-0}" -gt 0 ]]; then
export HAS_NVLINK=1
else
export HAS_NVLINK=0
fi
log "HAS_NVLINK=${HAS_NVLINK} (detected ${count} NVLink references)"
}
maybe_fill_env_server_url() {
if [[ "${USE_REMOTE_ENV}" == "1" && -z "${ENV_SERVER_URL}" ]]; then
export ENV_SERVER_URL="http://${ENV_SERVER_HOST}:${ENV_SERVER_PORT}"
if [[ "${START_ENV_POOL_SERVER}" == "0" ]]; then
export START_ENV_POOL_SERVER=1
fi
fi
log "ENV_SERVER_URL=${ENV_SERVER_URL} START_ENV_POOL_SERVER=${START_ENV_POOL_SERVER}"
}
start_ray_head() {
require_cmd ray
log "start ray head"
mkdir -p "${RAY_TMPDIR}"
ray start --head \
--node-ip-address "${MASTER_ADDR}" \
--num-gpus "${NUM_GPUS}" \
--disable-usage-stats \
--dashboard-host=0.0.0.0 \
--dashboard-port=8265 \
--temp-dir "${RAY_TMPDIR}"
}
build_runtime_env_json() {
python3 - <<'PY'
import json, os
conda_env = os.environ.get("ROUTER_CONDA_ENV_PATH", "")
py_ver = os.environ.get("CONDA_PYTHON_VERSION", "3.12")
site_packages = f"{conda_env}/lib/python{py_ver}/site-packages" if conda_env else ""
parts = [
os.environ.get("REPO_ROOT",""),
os.environ.get("SLIME_PKG_DIR",""),
os.environ.get("MEGATRON_DIR",""),
os.environ.get("SCRIPT_DIR",""),
site_packages,
]
pythonpath = ":".join([p for p in parts if p])
env_vars = {
"PYTHONPATH": pythonpath,
"CUDA_DEVICE_MAX_CONNECTIONS": "1",
"NCCL_NVLS_ENABLE": os.environ.get("HAS_NVLINK","0"),
"PYTORCH_CUDA_ALLOC_CONF": os.environ.get("PYTORCH_CUDA_ALLOC_CONF",""),
"USE_REMOTE_ENV": os.environ.get("USE_REMOTE_ENV","0"),
"ENV_SERVER_URL": os.environ.get("ENV_SERVER_URL",""),
}
print(json.dumps({"env_vars": env_vars}))
PY
}
submit_job() {
log "submit ray job"
local runtime_env_json
runtime_env_json="$(build_runtime_env_json)"
ray job submit --address="http://127.0.0.1:8265" \
--runtime-env-json="${runtime_env_json}" \
-- python3 ${SLIME_DIR}/train_async.py \
--actor-num-nodes 1 \
--actor-num-gpus-per-node "${ACTOR_GPUS}" \
--rollout-num-gpus "${ROLLOUT_GPUS}" \
"${MODEL_ARGS[@]}" \
"${CKPT_ARGS[@]}" \
"${ROLLOUT_ARGS[@]}" \
"${OPTIMIZER_ARGS[@]}" \
"${GRPO_ARGS[@]}" \
"${WANDB_ARGS[@]}" \
"${PERF_ARGS[@]}" \
"${EVAL_ARGS[@]}" \
"${SGLANG_ARGS[@]}" \
"${MISC_ARGS[@]}" \
"${CUSTOM_ARGS[@]}"
}
cleanup_prev
start_router
check_router
check_gpus
detect_nvlink
maybe_fill_env_server_url
export SCRIPT_DIR
start_ray_head
submit_job