fix(agent_wrapper): release Codex threads after each turn (#621)

* fix(agent_wrapper): release Codex threads after each turn

Loaded Codex threads and their per-thread MCP server subprocesses were
never unloaded from the app-server, growing file descriptors and child
processes monotonically until the wrapper was closed (fd exhaustion,
os error 24). Reply, streaming reply, and compact_session now
unsubscribe the thread in a turn-scoped finally block by default; the
new release_threads=False option restores the previous behavior.
Unsubscribe failures are logged and never break the turn, and threads
remain resumable after being unloaded.

Closes agentscope-ai/ReMe#609

* fix(agent_wrapper): keep tool-context guard on failed release and cover stream startup

A failed thread/unsubscribe left the thread loaded in the app-server
but dropped its tool-context entry, so a later resume with changed
tool_context_id or injected_job_kwargs would silently bypass the
constraint check. Keep the guard entry unless the unsubscribe request
succeeds.

reply_stream now releases the thread from an outer try/finally scoped
right after _open_thread, so threads are also unloaded when turn/start
raises or the caller is cancelled while awaiting it.
This commit is contained in:
Chen Wang 2026-10-16 13:59:30 +08:00 • committed by GitHub
parent 72e243b7d6
commit a6509ff479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 381 additions and 16 deletions

View file

@ -96,6 +96,7 @@ class CodexAgentWrapper(BaseAgentWrapper):
codex_bin: str | None = None,
config_overrides: list[str] | tuple[str, ...] | None = None,
experimental_api: bool = True,
release_threads: bool = True,
**kwargs,
) -> None:
if "launch_args_override" in kwargs:
@ -112,6 +113,7 @@ class CodexAgentWrapper(BaseAgentWrapper):
experimental_api=experimental_api,
)
self._codex: AsyncCodex | None = None
self._release_threads = release_threads
self._turn_lock = asyncio.Lock()
self._mcp_snapshot_path: Path | None = None
self._thread_tool_contexts: dict[str, str] = {}
@ -403,6 +405,25 @@ class CodexAgentWrapper(BaseAgentWrapper):
self._mcp_snapshot_path.unlink(missing_ok=True)
self._mcp_snapshot_path = None
async def _release_thread(self, codex: AsyncCodex, thread_id: str) -> None:
"""Unload a finished thread from the app-server so its MCP children exit."""
try:
from openai_codex.generated.v2_all import ThreadUnsubscribeResponse
# The SDK exposes no public unsubscribe; request() is public but
# reached through the private client attribute on SDK 0.162.x.
await codex._client.request( # pylint: disable=protected-access
"thread/unsubscribe",
{"threadId": thread_id},
response_model=ThreadUnsubscribeResponse,
)
except Exception as exc: # pylint: disable=broad-exception-caught
self.logger.warning(f"Failed to unsubscribe Codex thread {thread_id}: {exc}")
return
# The thread is still loaded when unsubscribe fails; keep its tool
# context so a later resume cannot silently bypass changed constraints.
self._thread_tool_contexts.pop(thread_id, None)
@staticmethod
def _serialize(value: Any) -> Any:
from pydantic_core import to_jsonable_python
@ -418,7 +439,11 @@ class CodexAgentWrapper(BaseAgentWrapper):
async with self._turn_lock:
codex = await self._get_codex()
thread = await self._open_thread(codex, kwargs)
result = await thread.run(inputs, **self._turn_kwargs(kwargs))
try:
result = await thread.run(inputs, **self._turn_kwargs(kwargs))
finally:
if self._release_threads:
await self._release_thread(codex, thread.id)
final_response = result.final_response or ""
response = {
@ -445,7 +470,11 @@ class CodexAgentWrapper(BaseAgentWrapper):
async with self._turn_lock:
codex = await self._get_codex()
thread = await codex.thread_resume(session_id)
await thread.compact()
try:
await thread.compact()
finally:
if self._release_threads:
await self._release_thread(codex, thread.id)
@classmethod
# pylint: disable=too-many-return-statements
@ -572,19 +601,23 @@ class CodexAgentWrapper(BaseAgentWrapper):
async with self._turn_lock:
codex = await self._get_codex()
thread = await self._open_thread(codex, kwargs)
turn = await thread.turn(inputs, **self._turn_kwargs(kwargs))
stream = turn.stream()
completed = False
try:
async for event in stream:
if event.method == "turn/completed":
completed = True
for chunk in self._event_to_chunks(event, thread.id):
yield chunk
turn = await thread.turn(inputs, **self._turn_kwargs(kwargs))
stream = turn.stream()
completed = False
try:
async for event in stream:
if event.method == "turn/completed":
completed = True
for chunk in self._event_to_chunks(event, thread.id):
yield chunk
finally:
if not completed:
try:
await turn.interrupt()
except Exception as exc: # pylint: disable=broad-exception-caught
self.logger.warning(f"Failed to interrupt Codex turn {turn.id}: {exc}")
await stream.aclose()
finally:
if not completed:
try:
await turn.interrupt()
except Exception as exc: # pylint: disable=broad-exception-caught
self.logger.warning(f"Failed to interrupt Codex turn {turn.id}: {exc}")
await stream.aclose()
if self._release_threads:
await self._release_thread(codex, thread.id)

View file

@ -653,6 +653,8 @@ async def test_compact_session_uses_native_thread_operation(tmp_path, monkeypatc
observed = {}
class FakeThread:
id = "thread-1"
async def compact(self):
observed["compacted"] = True
@ -1201,3 +1203,333 @@ def test_default_config_provides_codex_oauth_wrapper(monkeypatch):
assert "api_key" not in oauth
assert codex["auth_mode"] == "api_key"
assert codex["sandbox"] == "full-access"
class _RecordingClient:
def __init__(self, fail: bool = False):
self.requests = []
self.fail = fail
async def request(self, method, params, *, response_model):
self.requests.append({"method": method, "params": params, "response_model": response_model})
if self.fail:
raise RuntimeError("unsubscribe failed")
return SimpleNamespace(status="unsubscribed")
def _assert_unsubscribed(client, thread_id="thread-1"):
from openai_codex.generated.v2_all import ThreadUnsubscribeResponse
assert client.requests == [
{
"method": "thread/unsubscribe",
"params": {"threadId": thread_id},
"response_model": ThreadUnsubscribeResponse,
},
]
def _fake_codex(client, thread):
class FakeCodex:
def __init__(self, _config):
self._client = client
async def account(self):
return SimpleNamespace(account=SimpleNamespace())
async def close(self):
return None
async def thread_start(self, **_kwargs):
return thread
async def thread_resume(self, _thread_id, **_kwargs):
return thread
return FakeCodex
@pytest.mark.asyncio
async def test_reply_releases_thread_after_turn(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient()
class FakeThread:
id = "thread-1"
async def run(self, _inputs, **_kwargs):
return _TurnResult(final_response="done")
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
result = await wrapper.reply("answer")
await wrapper.close()
assert result["last_message"] == "done"
_assert_unsubscribed(client)
assert not wrapper._thread_tool_contexts # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_reply_releases_thread_when_turn_fails(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient()
class FakeThread:
id = "thread-1"
async def run(self, _inputs, **_kwargs):
raise ValueError("turn failed")
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
with pytest.raises(ValueError, match="turn failed"):
await wrapper.reply("answer")
await wrapper.close()
_assert_unsubscribed(client)
assert not wrapper._thread_tool_contexts # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_reply_ignores_unsubscribe_failure(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient(fail=True)
class FakeThread:
id = "thread-1"
async def run(self, _inputs, **_kwargs):
return _TurnResult(final_response="done")
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
result = await wrapper.reply("answer")
assert result["last_message"] == "done"
assert len(client.requests) == 1
# The thread is still loaded, so its tool-context guard must survive.
assert "thread-1" in wrapper._thread_tool_contexts # pylint: disable=protected-access
await wrapper.close()
@pytest.mark.asyncio
async def test_failed_release_keeps_tool_context_guard_on_resume(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient(fail=True)
class FakeThread:
id = "thread-1"
async def run(self, _inputs, **_kwargs):
return _TurnResult(final_response="done")
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
result = await wrapper.reply("answer", tool_context_id="ctx-a")
assert result["session_id"] == "thread-1"
assert len(client.requests) == 1
with pytest.raises(ValueError, match="cannot change"):
await wrapper.reply("answer", session_id="thread-1", tool_context_id="ctx-b")
await wrapper.close()
@pytest.mark.asyncio
async def test_release_threads_disabled_keeps_threads_loaded(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth", release_threads=False)
client = _RecordingClient()
class FakeThread:
id = "thread-1"
async def run(self, _inputs, **_kwargs):
return _TurnResult(final_response="done")
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
result = await wrapper.reply("answer")
assert result["last_message"] == "done"
assert not client.requests
assert "thread-1" in wrapper._thread_tool_contexts # pylint: disable=protected-access
await wrapper.close()
@pytest.mark.asyncio
async def test_reply_stream_releases_thread_on_completion(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient()
class FakeTurn:
id = "turn-1"
async def stream(self):
yield SimpleNamespace(
method="turn/completed",
payload=SimpleNamespace(
turn=SimpleNamespace(
id="turn-1",
status=SimpleNamespace(value="completed"),
duration_ms=1,
error=None,
),
),
)
async def interrupt(self):
raise AssertionError("Completed turns must not be interrupted")
class FakeThread:
id = "thread-1"
async def turn(self, _inputs, **_kwargs):
return FakeTurn()
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
chunks = [chunk async for chunk in wrapper.reply_stream("answer")]
await wrapper.close()
assert chunks[-1].chunk_type == ChunkEnum.REPLY_END
_assert_unsubscribed(client)
assert not wrapper._thread_tool_contexts # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_reply_stream_releases_thread_on_early_close(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient()
interrupt_count = 0
class FakeTurn:
id = "turn-1"
async def stream(self):
yield SimpleNamespace(
method="turn/started",
payload=SimpleNamespace(turn=SimpleNamespace(id=self.id)),
)
await asyncio.Event().wait()
async def interrupt(self):
nonlocal interrupt_count
interrupt_count += 1
class FakeThread:
id = "thread-1"
async def turn(self, _inputs, **_kwargs):
return FakeTurn()
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
stream = wrapper.reply_stream("answer")
first = await anext(stream)
assert first.chunk_type == ChunkEnum.REPLY_START
await stream.aclose()
await wrapper.close()
assert interrupt_count == 1
_assert_unsubscribed(client)
assert not wrapper._thread_tool_contexts # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_compact_session_releases_thread(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path)
client = _RecordingClient()
class FakeThread:
id = "thread-1"
async def compact(self):
return None
async def start():
return None
async def resume(_thread_id):
return FakeThread()
async def get_codex():
return SimpleNamespace(_client=client, thread_resume=resume)
monkeypatch.setattr(wrapper, "start", start)
monkeypatch.setattr(wrapper, "_get_codex", get_codex)
await wrapper.compact_session("thread-1")
_assert_unsubscribed(client)
assert not wrapper._thread_tool_contexts # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_reply_stream_releases_thread_when_turn_start_fails(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient()
class FakeThread:
id = "thread-1"
async def turn(self, _inputs, **_kwargs):
raise RuntimeError("turn/start failed")
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
with pytest.raises(RuntimeError, match="turn/start failed"):
_ = [chunk async for chunk in wrapper.reply_stream("answer")]
await wrapper.close()
_assert_unsubscribed(client)
assert not wrapper._thread_tool_contexts # pylint: disable=protected-access
@pytest.mark.asyncio
async def test_reply_stream_releases_thread_when_turn_start_is_cancelled(tmp_path, monkeypatch):
wrapper, _job = _wrapper(tmp_path, auth_mode="oauth")
client = _RecordingClient()
class FakeThread:
id = "thread-1"
async def turn(self, _inputs, **_kwargs):
raise asyncio.CancelledError("cancelled during turn/start")
monkeypatch.setattr(
"reme.components.agent_wrapper.codex_agent_wrapper.AsyncCodex",
_fake_codex(client, FakeThread()),
)
with pytest.raises(asyncio.CancelledError):
_ = [chunk async for chunk in wrapper.reply_stream("answer")]
await wrapper.close()
_assert_unsubscribed(client)
assert not wrapper._thread_tool_contexts # pylint: disable=protected-access