2a95791992
run_agent.py: delete the `# noqa: F401` re-export block (agent.process_bootstrap
OpenAI/_SafeWriter/_get_proxy_*, model_tools get_tool_definitions/
handle_function_call/check_toolset_requirements, FailoverReason,
_qwen_portal_headers/_routermint_headers, session_persistence names,
estimate_request_tokens_rough, ContextCompressor + friends, jittered_backoff,
prompt_builder names, message_sanitization names, tool_dispatch_helpers
names) — 41 names run_agent never used itself — and the `_STREAM_DIAG_HEADERS`
back-compat class alias (no in-tree reader). run_agent now imports only what
it uses (get_toolset_for_tool, is_local_endpoint, coalesce/uniquify tool-call
ids, cleanup_vm/get_active_env from terminal_tool_lifecycle).
agent/*: `_ra().X` late-binds that only reached a re-export now import the
defining module directly (agent_runtime_helpers -> process_bootstrap.OpenAI,
model_tools.handle_function_call, session_persistence._safe_session_filename_component;
agent_init -> model_tools.get_tool_definitions/check_toolset_requirements,
_lazy_headers("agent.client_lifecycle", ...) for qwen/routermint;
system_prompt -> agent.prompt_builder / model_tools directly, dropping its
own _ra() shim and the `_r` parameter threading). `_ra()` stays for
run_agent-resident names (logger, AIAgent, _hermes_home, _set_interrupt, ...).
toolsets.py: remove resolve_multiple_toolsets (shim-only, restored by
34abf954bd); tests/test_toolsets.py pins the same union behavior via
resolve_toolset over each name.
providers/__init__.py: drop the OMIT_TEMPERATURE re-export (no callers via the
package); ProviderProfile stays because __init__ uses it for annotations —
2 tests repointed to providers.base.
agent/iteration_budget.py: drop the "run_agent re-exports the class"
docstring pointer; 4 tests import IterationBudget from its home.
model_tools.py (arg_coercion names), agent/tool_executor.py, and
hermes_cli/cli_session_mixin.py repoints landed via a sibling commit on this
shared worktree.
Callers repointed: gateway/run.py, hermes_cli/cli_chat_turn_mixin.py,
hermes_cli/cli_tui_mixin.py, tui_gateway/session_workdir.py,
agent/transports/codex.py (one-line imports) + comment pointers in
tools/file_state.py, tools/schema_sanitizer.py, scripts/tool_search_livetest.py.
Tests: patch("run_agent.X") / monkeypatch.setattr(run_agent, "X") /
`from run_agent import X` -> defining module across 99 test files.
218 lines
7.8 KiB
Python
218 lines
7.8 KiB
Python
import sys
|
|
import threading
|
|
import time
|
|
import types
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import APIConnectionError
|
|
|
|
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
|
|
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
|
|
sys.modules.setdefault("fal_client", types.SimpleNamespace())
|
|
|
|
import run_agent
|
|
|
|
|
|
class FakeRequestClient:
|
|
def __init__(self, responder):
|
|
self._responder = responder
|
|
self._client = SimpleNamespace(is_closed=False)
|
|
self.chat = SimpleNamespace(
|
|
completions=SimpleNamespace(create=self._create)
|
|
)
|
|
self.responses = SimpleNamespace()
|
|
self.close_calls = 0
|
|
|
|
def _create(self, **kwargs):
|
|
return self._responder(**kwargs)
|
|
|
|
def close(self):
|
|
self.close_calls += 1
|
|
self._client.is_closed = True
|
|
|
|
|
|
class FakeSharedClient(FakeRequestClient):
|
|
pass
|
|
|
|
|
|
class OpenAIFactory:
|
|
def __init__(self, clients):
|
|
self._clients = list(clients)
|
|
self.calls = []
|
|
|
|
def __call__(self, **kwargs):
|
|
self.calls.append(dict(kwargs))
|
|
if not self._clients:
|
|
raise AssertionError("OpenAI factory exhausted")
|
|
return self._clients.pop(0)
|
|
|
|
|
|
def _build_agent(shared_client=None):
|
|
agent = run_agent.AIAgent.__new__(run_agent.AIAgent)
|
|
agent.api_mode = "chat_completions"
|
|
agent.provider = "openai-codex"
|
|
agent.base_url = "https://chatgpt.com/backend-api/codex"
|
|
agent.model = "gpt-5-codex"
|
|
agent.log_prefix = ""
|
|
agent.quiet_mode = True
|
|
agent._interrupt_requested = False
|
|
agent._interrupt_message = None
|
|
agent._client_lock = threading.RLock()
|
|
agent._client_kwargs = {"api_key": "***", "base_url": agent.base_url}
|
|
agent.client = shared_client or FakeSharedClient(lambda **kwargs: {"shared": True})
|
|
agent.stream_delta_callback = None
|
|
agent._stream_callback = None
|
|
agent.reasoning_callback = None
|
|
agent.status_callback = None
|
|
return agent
|
|
|
|
|
|
def _connection_error():
|
|
return APIConnectionError(
|
|
message="Connection error.",
|
|
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
|
|
)
|
|
|
|
|
|
def test_retry_after_api_connection_error_recreates_request_client(monkeypatch):
|
|
first_request = FakeRequestClient(lambda **kwargs: (_ for _ in ()).throw(_connection_error()))
|
|
second_request = FakeRequestClient(lambda **kwargs: {"ok": True})
|
|
factory = OpenAIFactory([first_request, second_request])
|
|
monkeypatch.setattr("agent.process_bootstrap.OpenAI", factory)
|
|
|
|
agent = _build_agent()
|
|
|
|
with pytest.raises(APIConnectionError):
|
|
agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
|
|
result = agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
|
|
assert result == {"ok": True}
|
|
assert len(factory.calls) == 2
|
|
assert first_request.close_calls >= 1
|
|
# The successful request's wire client is cached for reuse across
|
|
# sequential calls (not closed at request end); teardown really closes it.
|
|
assert second_request.close_calls == 0
|
|
agent._close_cached_request_openai_client(reason="agent_close")
|
|
assert second_request.close_calls == 1
|
|
|
|
|
|
def test_stale_non_stream_close_is_single_owner(monkeypatch):
|
|
def slow_responder(**kwargs):
|
|
time.sleep(0.1)
|
|
raise _connection_error()
|
|
|
|
request_client = FakeRequestClient(slow_responder)
|
|
factory = OpenAIFactory([request_client])
|
|
monkeypatch.setattr("agent.process_bootstrap.OpenAI", factory)
|
|
|
|
agent = _build_agent()
|
|
agent._compute_non_stream_stale_timeout = lambda api_payload: 0.01
|
|
|
|
with pytest.raises(APIConnectionError):
|
|
agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
|
|
assert request_client.close_calls == 1
|
|
|
|
|
|
def test_closed_shared_client_is_recreated_before_request(monkeypatch):
|
|
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
|
|
stale_shared._client.is_closed = True
|
|
|
|
replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True})
|
|
request_client = FakeRequestClient(lambda **kwargs: {"ok": "fresh-request-client"})
|
|
factory = OpenAIFactory([replacement_shared, request_client])
|
|
monkeypatch.setattr("agent.process_bootstrap.OpenAI", factory)
|
|
|
|
agent = _build_agent(shared_client=stale_shared)
|
|
result = agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
|
|
assert result == {"ok": "fresh-request-client"}
|
|
assert agent.client is replacement_shared
|
|
assert stale_shared.close_calls >= 1
|
|
assert replacement_shared.close_calls == 0
|
|
assert len(factory.calls) == 2
|
|
|
|
|
|
def test_concurrent_requests_do_not_break_each_other_when_one_client_closes(monkeypatch):
|
|
first_started = threading.Event()
|
|
first_closed = threading.Event()
|
|
|
|
def first_responder(**kwargs):
|
|
first_started.set()
|
|
first_client.close()
|
|
first_closed.set()
|
|
raise _connection_error()
|
|
|
|
def second_responder(**kwargs):
|
|
assert first_started.wait(timeout=2)
|
|
assert first_closed.wait(timeout=2)
|
|
return {"ok": "second"}
|
|
|
|
first_client = FakeRequestClient(first_responder)
|
|
second_client = FakeRequestClient(second_responder)
|
|
factory = OpenAIFactory([first_client, second_client])
|
|
monkeypatch.setattr("agent.process_bootstrap.OpenAI", factory)
|
|
|
|
agent = _build_agent()
|
|
results = {}
|
|
|
|
def run_call(name):
|
|
try:
|
|
results[name] = agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
except Exception as exc: # noqa: BLE001 - asserting exact type below
|
|
results[name] = exc
|
|
|
|
thread_one = threading.Thread(target=run_call, args=("first",), daemon=True)
|
|
thread_two = threading.Thread(target=run_call, args=("second",), daemon=True)
|
|
thread_one.start()
|
|
thread_two.start()
|
|
thread_one.join(timeout=5)
|
|
thread_two.join(timeout=5)
|
|
|
|
values = list(results.values())
|
|
assert sum(isinstance(value, APIConnectionError) for value in values) == 1
|
|
assert values.count({"ok": "second"}) == 1
|
|
assert len(factory.calls) == 2
|
|
|
|
|
|
|
|
def test_streaming_call_recreates_closed_shared_client_before_request(monkeypatch):
|
|
chunks = iter([
|
|
SimpleNamespace(
|
|
model="gpt-5-codex",
|
|
choices=[SimpleNamespace(delta=SimpleNamespace(content="Hello", tool_calls=None), finish_reason=None)],
|
|
),
|
|
SimpleNamespace(
|
|
model="gpt-5-codex",
|
|
choices=[SimpleNamespace(delta=SimpleNamespace(content=" world", tool_calls=None), finish_reason="stop")],
|
|
),
|
|
])
|
|
|
|
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
|
|
stale_shared._client.is_closed = True
|
|
|
|
replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True})
|
|
request_client = FakeRequestClient(lambda **kwargs: chunks)
|
|
factory = OpenAIFactory([replacement_shared, request_client])
|
|
monkeypatch.setattr("agent.process_bootstrap.OpenAI", factory)
|
|
|
|
agent = _build_agent(shared_client=stale_shared)
|
|
agent.stream_delta_callback = lambda _delta: None
|
|
# Force chat_completions mode so the streaming path uses
|
|
# chat.completions.create(stream=True) instead of Codex responses.stream()
|
|
agent.api_mode = "chat_completions"
|
|
response = agent._interruptible_streaming_api_call({"model": agent.model, "messages": []})
|
|
|
|
assert response.choices[0].message.content == "Hello world"
|
|
assert agent.client is replacement_shared
|
|
assert stale_shared.close_calls >= 1
|
|
# The clean stream's wire client is cached for reuse across sequential
|
|
# calls (not closed at request end); teardown really closes it.
|
|
assert request_client.close_calls == 0
|
|
agent._close_cached_request_openai_client(reason="agent_close")
|
|
assert request_client.close_calls == 1
|
|
assert len(factory.calls) == 2
|