Skip to content

fix(model-tools): reuse persistent async bridge loop - #16573

Open
chezzdev wants to merge 7 commits into
NousResearch:mainfrom
chezzdev:codex/fix-model-tools-async-bridge
Open

fix(model-tools): reuse persistent async bridge loop#16573
chezzdev wants to merge 7 commits into
NousResearch:mainfrom
chezzdev:codex/fix-model-tools-async-bridge

Conversation

@chezzdev

@chezzdev chezzdev commented Apr 27, 2026

Copy link
Copy Markdown

What and why

Fixes #16570.

When model_tools._run_async() is called from code that already owns an event loop, Hermes currently starts a new worker thread and a new event loop for every call. Cached async clients such as AsyncOpenAI and httpx can outlive those short-lived loops, leaving clients and transports tied to closed loops while the gateway keeps creating more threads and loops.

This PR changes only that running-loop path. Calls are submitted to a lazily started bridge loop with asyncio.run_coroutine_threadsafe(), while the existing main-thread and worker-thread paths keep their current behavior.

Details

  • Reuse one bridge loop instead of creating a disposable loop for every async-context tool call.
  • Preserve the caller's profile and approval/sudo routing for each submitted task. Concurrent callers keep separate context even though they share the bridge thread.
  • Keep the timeout cancellation behavior from fix(model_tools): cancel coroutine on timeout so worker thread exits + full traceback on tool failure #17428. A timed-out future is cancelled, and the bridge is retired only when a health check shows that it is no longer responsive.
  • Handle startup failures, nested calls from the bridge itself, retired-loop cleanup, and repeated shutdown safely.
  • Shut the bridge down from the existing CLI and gateway cleanup paths, after cached async clients have been closed.

The nested-call fallback still uses the existing propagate_context_to_thread() contract, so it does not lose profile or approval state when it has to move work to a one-off thread.

Related

Refs #8043. This PR addresses the running-loop/gateway part of that issue, including timeout and shutdown lifecycle. Cleanup for the persistent main-thread and per-worker loops remains separate.

Testing

Before/after probe

I ran the same no-mock probe against current main (10dc1571bc) and this branch (5f359bed3b). It calls the real _run_async() path 20 times from inside an active asyncio loop and retains the returned loop objects.

Current main This PR
Calls 20 20
Unique event loops 20 1
Loops closed after the calls 20 0

Regression suites

scripts/run_tests.sh tests/test_model_tools_async_bridge.py tests/test_profile_isolation_runtime.py -q
32 passed
scripts/run_tests.sh tests/acp/test_approval_isolation.py tests/tools/test_execute_code_approval_cluster.py tests/run_agent/test_tool_executor_contextvar_propagation.py tests/test_model_tools.py tests/cli/test_session_boundary_hooks.py tests/gateway/test_gateway_shutdown.py -q
98 passed

Also checked:

  • Ruff on the changed Python files
  • python -m py_compile on the bridge and context-propagation paths
  • git diff --check upstream/main...HEAD
  • scripts/check-windows-footguns.py --diff upstream/main

Tested on macOS with Python 3.13.12.

Checklist

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • I've run the complete pytest tests/ -q suite
  • I've added regression tests for the changed behavior
  • I've considered cross-platform impact
  • No configuration keys, tool schemas, or user-facing documentation changed

@chezzdev
chezzdev force-pushed the codex/fix-model-tools-async-bridge branch from 382152c to ea06a63 Compare April 27, 2026 13:42
@chezzdev
chezzdev marked this pull request as ready for review April 27, 2026 13:51
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard labels Apr 27, 2026
@chezzdev
chezzdev force-pushed the codex/fix-model-tools-async-bridge branch from c26200d to d5f448c Compare April 30, 2026 20:55
@chezzdev
chezzdev force-pushed the codex/fix-model-tools-async-bridge branch from d5f448c to 41e0889 Compare June 5, 2026 07:58

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough lifecycle and timeout coverage. The underlying issue remains present on current main: model_tools.py:113-169 still creates a per-call worker loop for a running event loop.

Problems

  • The new shared-loop submission at model_tools.py:373 bypasses current main's context and approval propagation wrapper at model_tools.py:146-151, added by 09af0a8c1d2ae55eaf78b9976619023fac41d041. tools/thread_context.py:4-14 documents that a bare worker loses profile ContextVars and approval/sudo callbacks; tests/test_profile_isolation_runtime.py:188-207 guards this exact _run_async() boundary.
  • The new bridge tests do not cover that current-main profile/approval contract, including concurrent bridge submissions.

Suggested changes

  • Salvage the persistent-loop design while preserving the later task-context and approval-routing guarantees, then add profile-isolation and concurrent approval-context regressions for the bridge path.

This is an automated hermes-sweeper review.

Comment thread model_tools.py Outdated
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
future = pool.submit(_run_in_worker)
bridge_loop = _get_async_bridge_loop()
future = asyncio.run_coroutine_threadsafe(coro, bridge_loop)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This schedules directly onto a shared bridge thread and drops the current-main propagate_context_to_thread contract added in 09af0a8c1d2ae55eaf78b9976619023fac41d041. That wrapper preserves the active profile and approval/sudo callbacks for async tool dispatch; please retain an equivalent task-safe mechanism and cover it with the current profile-isolation regression.

@chezzdev chezzdev Jul 13, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5f359bed3 after rebasing onto current main (55624e10b).

  • Shared-loop submissions now preserve the caller's existing ContextVar state and expose the current thread-local approval/sudo callbacks through a task-local override during run_coroutine_threadsafe() submission. TLS remains the CLI/ACP fallback, so concurrent bridge tasks cannot share callback state.
  • The nested one-off fallback now uses the existing propagate_context_to_thread() contract.
  • Added a concurrent two-profile/two-callback regression proving both submissions use the same bridge loop/thread while retaining isolated profile, approval, and sudo state before and after an await; also retained and ran the current profile-isolation regression.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 12, 2026
@chezzdev
chezzdev force-pushed the codex/fix-model-tools-async-bridge branch from 41e0889 to 5f359be Compare July 13, 2026 20:48
@chezzdev
chezzdev requested a review from teknium1 July 13, 2026 21:23

Copy link
Copy Markdown
Author

I ran a direct before/after check against today's main, using the real _run_async() path from inside an active event loop with no mocks. Across 20 calls, main created 20 distinct loops and all 20 were closed by the time the calls returned; this branch reused one live bridge loop for all 20. I added the exact results to the PR description.

The context-propagation concern from the review is covered as well: concurrent callers keep separate profile and approval/sudo state, and the nested fallback uses the existing thread-propagation path. I also linked #8043 as related, scoped to the running-loop/gateway part rather than claiming to close the whole issue. This should be ready for another look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: model_tools async bridge recreates loops in running-loop contexts

3 participants