From 680b85284590c49332b4ade16c500b304275d296 Mon Sep 17 00:00:00 2001 From: Evan Theiss Date: Tue, 5 May 2026 08:05:19 -0500 Subject: [PATCH] fix(core): instantiate each sub-client exactly once in _spawn_completion_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_spawn_completion_context` previously constructed the first sub-client TWICE — once at line 206 as `other_backend_client`, again at line 213 inside the registration loop with identical args. Two independent BaseLM instances pointed at the same sub-model. Impact: - For backends that hold expensive underlying state (HTTP connection pools, async clients, OS sockets), the duplicate carried real cost. We observed a 3-minute hang at iter 2 of every multi-backend trajectory on macOS with anthropic backends — fully recovered by de-duplicating the instantiation. - Usage tracking merged via dict.update at lm_handler.py:198-205, so the duplicate's summary over-wrote one of the two instances' usage data — silently under-counted cost in `get_usage_summary()`. Fix: build a single list of sub-clients from `zip(self.other_backends, self.other_backend_kwargs)` exactly once. The first entry serves as `other_backend_client` (depth=1 default routing when LMHandler.get_client receives no `model` arg) AND is registered by its `model_name` for explicit-model routing — same instance, both paths. Routing semantics are preserved: `get_client(model, depth)` at lm_handler.py:131 still returns `clients[model]` first, then falls back to `other_backend_client` for `depth==1`. Both now point to the same object for the first sub-model. Verified end-to-end downstream in delta360-rlm harness PR #6: - pre-fix: trajectory hung at iter 2 for 3+ minutes (0% CPU, 300s proxy/socket timeout suspected) - post-fix: 47.3s wall, $0.058 actual cost, 2 sub-calls, depth_reached=1 No behavior change for single-backend (no-sub_model) callers — the loop body is unchanged for them. Tests in delta360-rlm (`tests/test_multibackend_patch.py`) verify get_client call counts and instance-identity invariants for: (a) single sub-model, (b) no sub-models, (c) multiple sub-models. 5/5 pass. --- rlm/core/rlm.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/rlm/core/rlm.py b/rlm/core/rlm.py index 55b84c5de..6bf5416b5 100644 --- a/rlm/core/rlm.py +++ b/rlm/core/rlm.py @@ -200,18 +200,39 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]): # Create client and wrap in handler client: BaseLM = get_client(self.backend, self.backend_kwargs) - # Create other_backend_client if provided (for depth=1 routing) - other_backend_client: BaseLM | None = None + # Create one BaseLM instance per (backend, kwargs) pair. Each entry in + # `other_backends` / `other_backend_kwargs` is constructed exactly + # once. The first sub-client serves both as `other_backend_client` + # (depth=1 default routing when LMHandler.get_client receives no + # `model` arg) AND is registered by its `model_name` for explicit- + # model routing — same instance, both paths. + # + # Previous behavior constructed the first sub-client TWICE: once at + # line 206 (for `other_backend_client`) and again inside the loop at + # line 213 (for `register_client`). With backends that hold expensive + # underlying state (HTTP connection pools, async clients, OS sockets), + # the duplicate carried real cost; with multi-backend Anthropic on + # macOS we observed a 3-min hang at iter 2 of every multi-backend + # trajectory, fully recovered by de-dup. Usage tracking also merged + # via dict.update at lm_handler.py:198-205, so the duplicate + # over-wrote one of the two instances' usage summaries. + other_clients: list[BaseLM] = [] if self.other_backends and self.other_backend_kwargs: - other_backend_client = get_client(self.other_backends[0], self.other_backend_kwargs[0]) + for backend, kwargs in zip( + self.other_backends, self.other_backend_kwargs, strict=True, + ): + other_clients.append(get_client(backend, kwargs)) + + other_backend_client: BaseLM | None = ( + other_clients[0] if other_clients else None + ) lm_handler = LMHandler(client, other_backend_client=other_backend_client) - # Register other clients to be available as sub-call options (by model name) - if self.other_backends and self.other_backend_kwargs: - for backend, kwargs in zip(self.other_backends, self.other_backend_kwargs, strict=True): - other_client: BaseLM = get_client(backend, kwargs) - lm_handler.register_client(other_client.model_name, other_client) + # Register every sub-client (including `other_backend_client`) by its + # `model_name` — the SAME instance, NOT a new one. + for sub_client in other_clients: + lm_handler.register_client(sub_client.model_name, sub_client) lm_handler.start()