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