Skip to content

Commit dae8490

Browse files
committed
Single llm() failure in llm_batch() should not error out the whole batch
1 parent 8f7ce41 commit dae8490

7 files changed

Lines changed: 67 additions & 15 deletions

File tree

‎docs/api/rlm.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ The following functions are available to model-generated code inside the REPL:
550550
| Function | Description |
551551
|:---------|:------------|
552552
| `llm_query(prompt, model=None)` | Single plain LM completion. Fast, no REPL or iteration. |
553-
| `llm_query_batched(prompts, model=None)` | Multiple plain LM completions concurrently. |
553+
| `llm_query_batched(prompts, model=None)` | Multiple plain LM completions concurrently. A single failed call doesn't fail the batch — that slot returns `"Error: llm() call failed - <msg>"`, the rest return normally. |
554554
| `rlm_query(prompt, model=None)` | Spawn a child RLM with its own REPL for deeper thinking. Falls back to `llm_query` at max depth. |
555555
| `rlm_query_batched(prompts, model=None)` | Spawn multiple child RLMs. Falls back to `llm_query_batched` at max depth. |
556556
| `answer` | A dict (`{"content": "", "ready": False}`). Set `answer["content"]` to your final answer and `answer["ready"] = True` to terminate the run. |

‎docs/architecture.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ all prompts as a single batched request to the handler, which processes them
227227
concurrently with `asyncio.gather`. `rlm_query_batched` calls `subcall_fn`
228228
sequentially for each prompt (each child RLM is a blocking call).
229229

230+
**Failure handling is per-prompt.** A single failed call does not fail the
231+
whole batch — that slot returns the string `"Error: llm() call failed - <msg>"`
232+
(with the underlying error message) while the other prompts return their real
233+
responses. Results stay aligned with the input prompts by index, so the list is
234+
always the same length as `prompts`.
235+
230236
---
231237

232238
## Recursive Sub-Calls (Depth > 1)

‎rlm/core/comms_utils.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,12 @@ def send_lm_request_batched(
255255
if response.chat_completions is None:
256256
return [LMResponse.error_response("No completions returned")] * len(prompts)
257257

258-
# Convert batched response to list of individual responses
258+
# Convert batched response to list of individual responses. A completion
259+
# carrying an error means only that prompt failed; the rest still succeed.
259260
return [
260-
LMResponse.success_response(chat_completion)
261+
LMResponse.error_response(chat_completion.error)
262+
if chat_completion.error
263+
else LMResponse.success_response(chat_completion)
261264
for chat_completion in response.chat_completions
262265
]
263266
except Exception as e:

‎rlm/core/lm_handler.py‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ async def run_one(prompt: str):
9393

9494
async def run_all():
9595
tasks = [run_one(prompt) for prompt in request.prompts]
96-
return await asyncio.gather(*tasks)
96+
# return_exceptions=True so one failed call doesn't abort the whole
97+
# batch; failures are surfaced per-prompt as error completions below.
98+
return await asyncio.gather(*tasks, return_exceptions=True)
9799

98100
results = asyncio.run(run_all())
99101
end_time = time.perf_counter()
@@ -103,16 +105,31 @@ async def run_all():
103105
root_model = request.model or client.model_name
104106
usage_summary = UsageSummary(model_usage_summaries={root_model: model_usage})
105107

106-
chat_completions = [
107-
RLMChatCompletion(
108-
root_model=root_model,
109-
prompt=prompt,
110-
response=content,
111-
usage_summary=usage_summary,
112-
execution_time=total_time / len(request.prompts), # approximate per-prompt time
113-
)
114-
for prompt, content in zip(request.prompts, results, strict=True)
115-
]
108+
chat_completions = []
109+
for prompt, content in zip(request.prompts, results, strict=True):
110+
if isinstance(content, BaseException):
111+
# Per-prompt failure: this slot returns an error; other prompts
112+
# still succeed. The error message is carried back to the caller.
113+
chat_completions.append(
114+
RLMChatCompletion(
115+
root_model=root_model,
116+
prompt=prompt,
117+
response="",
118+
usage_summary=UsageSummary(model_usage_summaries={}),
119+
execution_time=0.0,
120+
error=f"llm() call failed - {content}",
121+
)
122+
)
123+
else:
124+
chat_completions.append(
125+
RLMChatCompletion(
126+
root_model=root_model,
127+
prompt=prompt,
128+
response=content,
129+
usage_summary=usage_summary,
130+
execution_time=total_time / len(request.prompts), # approximate per-prompt time
131+
)
132+
)
116133

117134
return LMResponse.batched_success_response(chat_completions=chat_completions)
118135

‎rlm/core/types.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ class RLMChatCompletion:
126126
metadata: dict | None = (
127127
None # Full trajectory (run_metadata + iterations) when logger captures it
128128
)
129+
error: str | None = None # Set when this single call failed (e.g. in a batch); response is empty.
129130

130131
def to_dict(self):
131132
out = {
@@ -137,6 +138,8 @@ def to_dict(self):
137138
}
138139
if self.metadata is not None:
139140
out["metadata"] = self.metadata
141+
if self.error is not None:
142+
out["error"] = self.error
140143
return out
141144

142145
@classmethod
@@ -148,6 +151,7 @@ def from_dict(cls, data: dict) -> "RLMChatCompletion":
148151
usage_summary=UsageSummary.from_dict(data.get("usage_summary")),
149152
execution_time=data.get("execution_time"),
150153
metadata=data.get("metadata"),
154+
error=data.get("error"),
151155
)
152156

153157

‎tests/test_lm_handler.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,28 @@ def test_lm_handler_batched_request():
3030
assert resp.chat_completion.response == f"r{i}"
3131

3232

33+
def test_lm_handler_batched_partial_failure():
34+
"""One failing call returns an error for that slot; the rest still succeed."""
35+
36+
def response_fn(prompt):
37+
if prompt == "prompt-1":
38+
raise RuntimeError("boom")
39+
return f"ok {prompt}"
40+
41+
mock = MockLM(response_fn=response_fn)
42+
with LMHandler(client=mock, batch_max_concurrent=3) as handler:
43+
prompts = ["prompt-0", "prompt-1", "prompt-2"]
44+
result = send_lm_request_batched(handler.address, prompts)
45+
46+
assert len(result) == 3
47+
assert result[0].success
48+
assert result[0].chat_completion.response == "ok prompt-0"
49+
assert not result[1].success
50+
assert "boom" in result[1].error
51+
assert result[2].success
52+
assert result[2].chat_completion.response == "ok prompt-2"
53+
54+
3355
def test_lm_handler_batched_many_prompts_semaphore_cap():
3456
"""Many prompts complete successfully with semaphore limiting concurrency."""
3557
# 50 prompts, max 4 concurrent: should still all complete

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)