From 2e30a5e62891ea07afb631ebca693dbb01c4468a Mon Sep 17 00:00:00 2001 From: isheng Date: Tue, 7 Jul 2026 13:30:24 +0800 Subject: [PATCH] fix: prevent /stop signal loss and empty provider credential corruption Two deep bugs found through systematic analysis of the streaming API call and fallback credential subsystems: 1. Interrupt signal loss (chat_completion_helpers.py): When the worker thread exits before the main thread's poll loop checks the interrupt flag (e.g. _call_anthropic() detects the flag and returns None), the while loop exits normally and the InterruptedError is never raised. /stop is silently swallowed. Fix: re-check _interrupt_requested after the while loop exits. 2. Empty provider bypasses credential guard (agent_runtime_helpers.py): recover_with_credential_pool() guards against cross-provider pool swaps with 'if current_provider and pool_provider and current != pool_provider'. When agent.provider is '' (valid unset state from agent_init.py:326), current_provider is falsy, the guard is skipped, and the pool swaps credentials onto an agent with empty provider. This is the root cause of the 'provider= model=' empty-string error. Fix: only skip the guard when pool_provider is empty (unscoped pool), not when agent provider is empty. --- agent/agent_runtime_helpers.py | 9 ++++++++- agent/chat_completion_helpers.py | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 691d796273f..b3a9cf5321c 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -729,7 +729,14 @@ def recover_with_credential_pool( # that seeded the pool. current_provider = (getattr(agent, "provider", "") or "").strip().lower() pool_provider = (getattr(pool, "provider", "") or "").strip().lower() - if current_provider and pool_provider and current_provider != pool_provider: + # Guard: skip credential pool recovery when the pool is scoped to a + # different provider than the agent. Only guard when the pool has a + # known provider — an empty pool provider means "unscoped" (applies to + # any provider). An empty agent provider is treated as a mismatch + # because swapping the pool's credentials would set base_url/api_key + # without fixing the empty provider field, leaving the agent in a + # corrupted state (provider="" model=""). + if pool_provider and current_provider != pool_provider: # Custom endpoints use two naming conventions for the SAME provider: # the agent carries the generic ``custom`` label while the pool is # keyed ``custom:`` (see CUSTOM_POOL_PREFIX). A literal string diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index d2dc4745c03..0c4968b5fc6 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2902,6 +2902,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= except Exception: pass raise InterruptedError("Agent interrupted during streaming API call") + # Worker thread exited before the main thread's poll loop could check + # the interrupt flag. If the worker returned early due to an interrupt + # (e.g. _call_anthropic() detected _interrupt_requested and returned + # None), the InterruptedError above was never raised. Re-check the + # flag here so /stop is not silently swallowed. (#59999 area) + if agent._interrupt_requested: + raise InterruptedError("Agent interrupted during streaming API call (post-worker)") if result["error"] is not None: if deltas_were_sent["yes"]: # Streaming failed AFTER some tokens were already delivered to