Skip to content

Commit 6229f3f

Browse files
committed
formatting fixes, try to reduce the frequency of LiteLLM failures
Signed-off-by: Anna Shors <ashors@nvidia.com>
1 parent 27f5c64 commit 6229f3f

7 files changed

Lines changed: 377 additions & 46 deletions

File tree

examples/configs/grpo_tau_bench.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ grpo:
2424

2525
checkpointing:
2626
checkpoint_dir: "results/grpo-tau-bench"
27-
metric_name: "val:tau_bench/task_completion_rate"
27+
metric_name: "val:accuracy"
2828
higher_is_better: true
2929

3030
policy:
@@ -46,7 +46,7 @@ policy:
4646
max_new_tokens: 512 # per turn; tool calls are short
4747
temperature: 1.0
4848
top_p: 1.0
49-
stop_strings: null # stop strings are supplied per-turn by the environment
49+
stop_strings: null # turn separator prepended to each observation by _tokenize_env_observation
5050
stop_token_ids: null
5151
vllm_cfg:
5252
async_engine: false
@@ -90,6 +90,10 @@ env:
9090
judge_base_url: null # unused when judge_model="mock"
9191
judge_api_key: null # unused when judge_model="mock"
9292
judge_weight: 0.3 # 70% task-completion + 30% judge score
93+
# Stagger API requests across workers to avoid a thundering-herd burst.
94+
# Worker i sleeps i * worker_stagger_delay_s before its first API call.
95+
# Set to 0 to disable. Ignored when user_strategy="mock".
96+
worker_stagger_delay_s: 1.0
9397
mock_user_latency_s: 0.1 # seconds to sleep per mock user simulator call
9498
mock_judge_latency_s: 0.1 # seconds to sleep per mock judge call
9599
mock_stop_prob: 0.4

nemo_rl/data/datasets/response_datasets/tau_bench_dataset.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,17 @@ def _build_records(tau_bench_env_name: str, split: str) -> list[dict[str, Any]]:
9999
# time. We read task.instruction directly — env.reset() is intentionally
100100
# NOT called here because HumanUserSimulationEnv.reset() blocks on
101101
# input(), which would hang the process for every task.
102+
# task_index=0: tau_bench's default (task_index=None) uses
103+
# random.randint(0, len(tasks)) which is inclusive on both ends and
104+
# occasionally returns len(tasks), causing an IndexError. We only need
105+
# the env for its metadata (tools, wiki, rules, task list), so any valid
106+
# index works.
102107
env = get_env(
103108
env_name=tau_bench_env_name,
104109
user_strategy="human",
105110
user_model="",
106111
task_split=split,
112+
task_index=0,
107113
)
108114

109115
system_prompt = _build_system_prompt(env, tau_bench_env_name)

nemo_rl/data/processors.py

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -721,31 +721,26 @@ def tau_bench_data_processor(
721721
- extra_env_info: {task_index, episode_id, step_count}
722722
- task_name: "tau_bench"
723723
724-
The full system + user prompt is collapsed into a single message_log entry
725-
(role "user") whose content is the chat-template-formatted string. This is
726-
the same layout used by math_hf_data_processor and is required because the
727-
multi-turn rollout loop appends subsequent turns on top of this entry.
724+
Each message in messages ([system_message, initial_user_message]) is kept as
725+
its own message_log entry with its correct role. get_formatted_message_log
726+
tokenizes each message's delta incrementally so that token_ids for each entry
727+
covers only that message's tokens, not the full conversation. The assistant
728+
generation prompt is appended to the last user message so the rollout loop
729+
can begin generating immediately.
728730
"""
729731
messages = datum_dict["messages"]
730732
extra_env_info = datum_dict["extra_env_info"]
731733

732-
formatted: str = tokenizer.apply_chat_template( # type: ignore[assignment]
734+
message_log: LLMMessageLogType = get_formatted_message_log(
733735
messages,
734-
tokenize=False,
736+
tokenizer,
737+
task_data_spec,
738+
add_bos_token=True,
739+
add_eos_token=False,
735740
add_generation_prompt=True,
736-
add_special_tokens=False,
737741
)
738-
token_ids = tokenizer(
739-
formatted,
740-
return_tensors="pt",
741-
add_special_tokens=False,
742-
)["input_ids"][0]
743-
744-
message_log: LLMMessageLogType = [
745-
{"role": "user", "content": formatted, "token_ids": token_ids}
746-
]
747742

748-
length = len(token_ids)
743+
length = sum(len(msg["token_ids"]) for msg in message_log)
749744
loss_multiplier = 1.0
750745
if length >= max_seq_length:
751746
for msg in message_log:

nemo_rl/environments/tau_bench_environment.py

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@
3232

3333
_TOOL_CALL_RE = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
3434

35+
# Substrings that indicate a transient upstream API failure worth retrying.
36+
_TRANSIENT_HTTP_PHRASES = (
37+
"502", "503", "504",
38+
"bad gateway", "service unavailable",
39+
"timeout", "timed out",
40+
"429", "rate limit",
41+
)
42+
3543
_JUDGE_SYSTEM_PROMPT = """\
3644
You are evaluating a customer service AI agent. You will be given the domain rules and \
3745
policies the agent must follow, the customer's original request, and the full conversation.
@@ -67,6 +75,10 @@ class TauBenchEnvConfig(TypedDict):
6775
Ignored when user_strategy is "mock".
6876
judge_api_key: API key for the judge model server. Ignored when user_strategy is "mock".
6977
judge_weight: Blend weight: 0.0 = pure tau-bench reward, 1.0 = pure judge score.
78+
worker_stagger_delay_s: Seconds to delay each successive worker's first API call.
79+
Spreading requests avoids a thundering-herd burst that can overwhelm the
80+
inference endpoint. Worker i sleeps i * worker_stagger_delay_s before its
81+
first API call. Default: 1.0.
7082
mock_user_latency_s: Seconds to sleep per mock user simulator call.
7183
mock_judge_latency_s: Seconds to sleep per mock judge call.
7284
mock_stop_prob: Probability that the mock user simulator ends the conversation on
@@ -85,6 +97,7 @@ class TauBenchEnvConfig(TypedDict):
8597
judge_base_url: NotRequired[str | None]
8698
judge_api_key: NotRequired[str | None]
8799
judge_weight: float
100+
worker_stagger_delay_s: NotRequired[float]
88101
mock_user_latency_s: NotRequired[float | None]
89102
mock_judge_latency_s: NotRequired[float | None]
90103
mock_stop_prob: NotRequired[float | None]
@@ -196,6 +209,45 @@ def _mock_completion(*args: Any, **kwargs: Any) -> "_Resp":
196209
os.environ["OPENAI_API_KEY"] = user_api_key
197210
elif "OPENAI_API_KEY" not in os.environ:
198211
os.environ["OPENAI_API_KEY"] = "dummy"
212+
# Set a global request timeout so truly hung API calls fail within a
213+
# bounded time. The timeout must be long enough to accommodate
214+
# legitimately slow inference responses (the NVIDIA inference endpoint
215+
# can take 60-90s under load); a timeout that is too short converts
216+
# normal slow responses into spurious failures and retry storms.
217+
# 120s gives ample headroom for slow responses while still bounding
218+
# genuinely stuck connections (vs the OS TCP timeout of ~10 min).
219+
import litellm as _litellm
220+
_litellm.request_timeout = 120
221+
222+
def _call_with_retry(self, fn: Any, max_retries: int = 5, base_delay: float = 2.0) -> Any:
223+
"""Call fn, retrying on transient API errors with exponential back-off.
224+
225+
Uses full-jitter backoff (uniform sample in [0, delay]) so that many
226+
workers retrying simultaneously don't all hit the API at the same instant.
227+
228+
Re-raises as RuntimeError on exhaustion so Ray can serialize the
229+
exception across actor boundaries (litellm exception classes are not
230+
Ray-serializable and produce UnserializableException in the driver).
231+
"""
232+
for attempt in range(max_retries + 1):
233+
try:
234+
return fn()
235+
except Exception as e:
236+
if attempt >= max_retries:
237+
raise RuntimeError(
238+
f"[TauBench] API call failed after {max_retries + 1} attempts: {e}"
239+
) from None
240+
msg = str(e).lower()
241+
if any(phrase in msg for phrase in _TRANSIENT_HTTP_PHRASES):
242+
delay = random.uniform(0, base_delay * (2 ** attempt))
243+
print(
244+
f"[TauBench] transient API error on attempt {attempt + 1}/{max_retries + 1},"
245+
f" retrying in {delay:.1f}s: {type(e).__name__}: {e}",
246+
flush=True,
247+
)
248+
time.sleep(delay)
249+
else:
250+
raise RuntimeError(f"[TauBench] non-retryable API error: {type(e).__name__}: {e}") from None
199251

200252
def _make_env(self, task_index: int) -> tuple:
201253
"""Create and reset a tau-bench Env for a specific task index.
@@ -221,20 +273,35 @@ def _make_env(self, task_index: int) -> tuple:
221273
if self._mock_user:
222274
user_provider = user_provider or "openai"
223275

224-
env = get_env(
225-
env_name=self._env_name,
226-
user_strategy=tau_user_strategy,
227-
user_model=user_model,
228-
task_split=self._task_split,
229-
user_provider=user_provider,
230-
task_index=task_index,
231-
)
232276
if self._mock_user:
233277
from unittest.mock import patch
278+
env = get_env(
279+
env_name=self._env_name,
280+
user_strategy=tau_user_strategy,
281+
user_model=user_model,
282+
task_split=self._task_split,
283+
user_provider=user_provider,
284+
task_index=task_index,
285+
)
234286
with patch("litellm.completion", self._mock_completion):
235287
reset_response = env.reset(task_index=task_index)
236288
else:
237-
reset_response = env.reset(task_index=task_index)
289+
# Both get_env() and env.reset() make LLM API calls:
290+
# LLMUserSimulationEnv.__init__ calls self.reset() during get_env(),
291+
# and env.reset() generates the customer's opening message.
292+
# Wrap both together so a transient failure retries from scratch.
293+
def _create_and_reset() -> tuple:
294+
env = get_env(
295+
env_name=self._env_name,
296+
user_strategy=tau_user_strategy,
297+
user_model=user_model,
298+
task_split=self._task_split,
299+
user_provider=user_provider,
300+
task_index=task_index,
301+
)
302+
return env, env.reset(task_index=task_index)
303+
304+
env, reset_response = self._call_with_retry(_create_and_reset)
238305
initial_obs = str(reset_response.observation)
239306
return env, initial_obs
240307

@@ -315,6 +382,7 @@ def execute(
315382
message_batch: List[str],
316383
metadata_batch: List[TauBenchEnvMetadata],
317384
judge_weight: float,
385+
initial_delay_s: float = 0.0,
318386
) -> Tuple[
319387
List[Dict[str, str]],
320388
List[bool],
@@ -327,10 +395,14 @@ def execute(
327395
message_batch: Latest assistant response texts, one per sample.
328396
metadata_batch: Per-episode state dicts, one per sample.
329397
judge_weight: Blending weight between tau-bench reward and judge score.
398+
initial_delay_s: Seconds to sleep before issuing any API request.
399+
Used to stagger workers so they don't all hit the endpoint at once.
330400
331401
Returns:
332402
Tuple of (observations, terminateds, rewards, updated_metadata).
333403
"""
404+
if initial_delay_s > 0.0:
405+
time.sleep(initial_delay_s)
334406
observations: List[Dict[str, str]] = []
335407
terminateds: List[bool] = []
336408
rewards: List[float] = []
@@ -346,10 +418,12 @@ def execute(
346418
self._active_envs[episode_id] = {
347419
"env": tau_env,
348420
"initial_obs": initial_obs,
421+
"conversation": [], # list of (label, text) for episode logging
349422
}
350423

351424
env_state = self._active_envs[episode_id]
352425
tau_env = env_state["env"]
426+
conversation: List[Tuple[str, str]] = env_state["conversation"]
353427

354428
if env_state.get("initial_obs") is not None:
355429
# Pre-step: return the user simulator's actual opening message
@@ -358,6 +432,14 @@ def execute(
358432
# discarded here; the agent will generate its real first response
359433
# on the next turn after seeing the customer's actual message.
360434
initial_obs = env_state.pop("initial_obs")
435+
# Record the wasted agent response and the real customer opening.
436+
conversation.append(("AGENT", agent_text.strip()))
437+
conversation.append(("CUSTOMER", initial_obs.strip()))
438+
#print(
439+
# f"[TauBench] episode={episode_id} task={task_index}"
440+
# f" PRE-STEP: customer: {initial_obs[:200]}",
441+
# flush=True,
442+
#)
361443
observations.append({"role": "user", "content": initial_obs})
362444
terminateds.append(False)
363445
rewards.append(0.0)
@@ -376,8 +458,19 @@ def execute(
376458
with patch("litellm.completion", self._mock_completion):
377459
env_response = tau_env.step(action)
378460
else:
379-
env_response = tau_env.step(action)
461+
env_response = self._call_with_retry(lambda: tau_env.step(action))
380462
step_count += 1
463+
#print(
464+
# f"[TauBench] episode={episode_id} task={task_index}"
465+
# f" step={step_count} action={action.name}",
466+
# flush=True,
467+
#)
468+
469+
# Record this agent turn and the resulting observation.
470+
obs_role = "user" if action.name == "respond" else "tool"
471+
obs_label = "CUSTOMER" if obs_role == "user" else "TOOL"
472+
conversation.append(("AGENT", agent_text.strip()))
473+
conversation.append((obs_label, str(env_response.observation).strip()))
381474

382475
done = bool(env_response.done) or step_count >= self._max_steps
383476
reward = 0.0
@@ -412,11 +505,51 @@ def execute(
412505
else:
413506
reward = tau_reward
414507

508+
# Log episode summary with clean text (no template markup).
509+
blended = reward
510+
tasks_list = getattr(tau_env, "tasks", [])
511+
task_instruction = (
512+
tasks_list[task_index].instruction[:150]
513+
if tasks_list and task_index < len(tasks_list)
514+
else ""
515+
)
516+
score_str = f"tau_reward={tau_reward:.3f}"
517+
if judge_score is not None:
518+
score_str += f" judge_score={judge_score:.3f}"
519+
score_str += f" blended={blended:.3f}"
520+
#print(f"\n{'='*70}")
521+
#print(
522+
# f"[TauBench] episode_id={episode_id} task_index={task_index} steps={step_count}\n"
523+
# f" {score_str}"
524+
#)
525+
#if task_instruction:
526+
# print(f" task: {task_instruction}")
527+
#print("-" * 70)
528+
#print(" CONVERSATION:")
529+
for turn_num, (label, text) in enumerate(conversation, 1):
530+
if len(text) > 300:
531+
text = text[:300] + " ..."
532+
#print(f" [{turn_num}] {label}: {text}")
533+
#print("-" * 70)
534+
tool_calls: List[Tuple[str, Any]] = []
535+
for _label, _text in conversation:
536+
if _label == "AGENT":
537+
for _m in _TOOL_CALL_RE.finditer(_text):
538+
try:
539+
_p = json.loads(_m.group(1).strip())
540+
tool_calls.append((_p.get("name", "?"), _p.get("arguments", {})))
541+
except (json.JSONDecodeError, KeyError):
542+
pass
543+
"""if tool_calls:
544+
print(" AGENT ACTIONS (tool calls):")
545+
for i, (name, kwargs) in enumerate(tool_calls):
546+
print(f" [{i}] {name}: {kwargs}")
547+
else:
548+
print(" AGENT ACTIONS (tool calls): none")
549+
print("=" * 70, flush=True)"""
550+
415551
del self._active_envs[episode_id]
416552

417-
# Use "user" for user-simulator responses and "tool" for tool results
418-
# so rollouts.py can apply the correct chat-template role markers.
419-
obs_role = "user" if action.name == "respond" else "tool"
420553
observations.append(
421554
{"role": obs_role, "content": str(env_response.observation)}
422555
)
@@ -465,10 +598,22 @@ def __init__(self, cfg: TauBenchEnvConfig) -> None:
465598
self.cfg = cfg
466599
self._num_workers = cfg["num_workers"]
467600
self._judge_weight = float(cfg["judge_weight"])
601+
self._stagger_delay_s = float(cfg.get("worker_stagger_delay_s", 1.0))
602+
603+
# TauBenchWorkers call external LLM APIs that may only be reachable from
604+
# specific nodes (e.g. the head node has outbound internet, compute nodes
605+
# do not). Pin workers to the same node that hosts TauBenchEnvironment
606+
# itself so they share the same network access.
607+
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
608+
_this_node = ray.get_runtime_context().get_node_id()
609+
_schedule_here = NodeAffinitySchedulingStrategy(
610+
node_id=_this_node, soft=False
611+
)
468612

469613
self._workers = [
470614
TauBenchWorker.options(
471-
runtime_env={"py_executable": PY_EXECUTABLES.TAU_BENCH}
615+
runtime_env={"py_executable": PY_EXECUTABLES.TAU_BENCH},
616+
scheduling_strategy=_schedule_here,
472617
).remote(
473618
env_name=cfg["env_name"],
474619
task_split=cfg["task_split"],
@@ -519,6 +664,7 @@ def step(
519664
chunked_messages[i],
520665
chunked_metadata[i],
521666
self._judge_weight,
667+
i * self._stagger_delay_s,
522668
)
523669
for i in range(self._num_workers)
524670
]

0 commit comments

Comments
 (0)