Skip to content

feat(gateway): session model pool — concurrency-aware auto-assignment with auxiliary slot tracking - #37519

Open
ricardo-camilo-programador-frontend-web wants to merge 12 commits into
NousResearch:mainfrom
ricardo-camilo-programador-frontend-web:feat/session-model-pool
Open

feat(gateway): session model pool — concurrency-aware auto-assignment with auxiliary slot tracking#37519
ricardo-camilo-programador-frontend-web wants to merge 12 commits into
NousResearch:mainfrom
ricardo-camilo-programador-frontend-web:feat/session-model-pool

Conversation

@ricardo-camilo-programador-frontend-web

Copy link
Copy Markdown

Problem or Use Case

Users running multiple concurrent sessions (Discord threads, Telegram topics, WhatsApp chats, etc.) must manually run /model and /yolo in every new session. Without intentional distribution, multiple sessions hit the same model, exhausting provider rate limits.

Additionally, auxiliary tasks (vision, compression, curator, session_search, approval, etc.) make independent API calls that are invisible to session management — meaning even a perfect session distribution can cause rate-limit collisions when an auxiliary call saturates a model already used by active sessions.

Concrete scenario: With the Z.AI Coding Plan, 7+ GLM models are available with separate concurrency pools totaling 28+ slots. Today only 1 session uses capacity before collisions start, and auxiliary calls further erode available slots unpredictably.

Proposed Solution

Add a session_model_pool section to config.yaml that:

  1. Auto-assigns the next available model when the gateway creates a new session, respecting per-model concurrency limits
  2. Tracks auxiliary tool calls against reserved slots per model, preventing auxiliary calls from stealing session capacity
  3. Provides multiple allocation strategies (round-robin, least-loaded, priority)

New files

  • gateway/session_model_pool.py — Thread-safe SessionModelPool class with PoolModelEntry dataclass, 3 allocation strategies, session + auxiliary slot tracking, inactive timeout, and module-level singleton (get_session_model_pool())
  • tests/gateway/test_session_model_pool.py — 35 unit tests covering pool entry logic, all 3 strategies, session/auxiliary slot management, thread safety, edge cases

Modified files

  • gateway/run.py — 3 integration points:
    • _resolve_session_agent_runtime: pool assigns model when no manual /model override exists (stores in _pool_assigned_models dict)
    • _handle_message_with_agent: releases pool slot on session reset
    • _handle_model_command: releases pool slot when user makes manual /model override (override always prevails)
  • agent/auxiliary_client.pycall_llm() acquires auxiliary slot before API call, releases in finally block
  • hermes_cli/config.pysession_model_pool section added to DEFAULT_CONFIG

Proposed config schema

session_model_pool:
  enabled: true
  strategy: round-robin              # round-robin | least-loaded | priority
  inactive_timeout: 1800             # seconds — slot released after inactivity
  pool:
    - model: glm-5-turbo
      provider: zai
      max_concurrent: 1
      reserved_for_auxiliary: 0
      context_length: 203000
    - model: glm-5
      provider: zai
      max_concurrent: 2
      reserved_for_auxiliary: 1
      context_length: 200000
    - model: glm-4.6
      provider: zai
      max_concurrent: 3
      reserved_for_auxiliary: 2
      context_length: 200000
    - model: glm-4.5
      provider: zai
      max_concurrent: 10
      reserved_for_auxiliary: 0
      context_length: 128000

Field definitions

  • max_concurrent (required) — Hard limit on simultaneous uses (sessions + auxiliary)
  • reserved_for_auxiliary (required) — Guaranteed minimum slots for auxiliary tool calls; sessions can only use max_concurrent - reserved_for_auxiliary slots
  • context_length (optional) — If set, gateway may warn on context overflow
  • priority (optional, 1–10, default 5) — Higher = preferred when multiple models have free slots

Behavior

  • session_model_pool.disabled → current behavior (global model.default), no overhead
  • Manual /model override always prevails — pool slot is released and session is marked as "manually overridden"
  • All session slots saturated → falls through to model.default
  • Auxiliary call blocked → retries with backoff, then skips (non-critical) or surfaces error (critical like vision)
  • Gateway restart → in-memory state lost; sessions reassigned on first message

Test results

  • 35/35 new unit tests passing
  • 2426/2427 existing gateway tests passing (1 pre-existing Matrix failure unrelated)

Closes #37511

… with auxiliary slot tracking

Add SessionModelPool module that automatically assigns models to new
sessions from a configured pool, respecting per-model concurrency limits
and reserving slots for auxiliary tasks (vision, compression, curator,
etc.).

New config section: session_model_pool
- enabled (bool, default false)
- strategy: round-robin | least-loaded | priority
- inactive_timeout: seconds before idle slot release
- pool[]: list of model entries with max_concurrent,
  reserved_for_auxiliary, context_length, priority

Gateway integration (run.py):
- _resolve_session_agent_runtime: pool assigns model when no manual
  /model override exists, stores in _pool_assigned_models dict
- _handle_message_with_agent: releases pool slot on session reset
- _handle_model_command: releases pool slot on manual /model override

Auxiliary integration (auxiliary_client.py):
- call_llm: acquires auxiliary slot before API call, releases in
  finally block

Tests: 35 unit tests covering pool entry logic, all 3 strategies,
session/auxiliary slot management, thread safety, and edge cases.

Closes NousResearch#37511
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery labels Jun 2, 2026
ricardocamiloconsir

This comment was marked as resolved.

@ricardo-camilo-programador-frontend-web

This comment was marked as resolved.

@ricardo-camilo-programador-frontend-web

This comment was marked as resolved.

@ricardo-camilo-programador-frontend-web

This comment was marked as resolved.

@ricardo-camilo-programador-frontend-web

This comment was marked as resolved.

- Remove read_raw_config() from auxiliary_client hot path (H4/R2)
  Singleton caches config; disk I/O on every call was wasteful.
- Verify auxiliary slot release in finally block (H1/R2)
  Original code already had correct finally block; subagents
  reviewed committed diff (pre-fix) and flagged false positive.
- _pool_assigned_models_lock protects all 5 access sites (H3/R1)
- threading.Condition replaces polling for aux slots (H5/R1)
- Per-session timestamps in _evict_inactive_sessions (B2/R1)
- Stoic quotes fully removed from run.py (B1/R1)
- Singleton restart documented in module docstring (H2/R1)
- B4: Gate auxiliary LLM call when acquire_auxiliary_slot returns False
  (previously the call proceeded unconditionally, defeating throttling)
- H1/H6: Propagate pool-assigned provider to runtime_kwargs so the
  session uses the correct provider endpoint, not just the model name
- H3: Integrate mark_manual_override in /model handler so the pool
  won't reassign manually-overridden sessions on the next turn
@ricardocamiloconsir

This comment was marked as resolved.

- H5: Extract _release_pool_slot() helper, replace 3 duplicated release blocks
- M1: Warn on duplicate pool_key in from_config()
- M4: Replace silent except Exception:pass with logger.debug in all pool paths
- Fix inactive_timeout validation to accept floats (not just int)
- Fix misleading test comment (round-robin, not priority)
- Add 9 new tests: eviction (4), duplicate pool_key (2), aux slot blocking (3)

44/44 tests passing.
@ricardocamiloconsir

This comment was marked as resolved.

ricardocamiloconsir

This comment was marked as resolved.

@ricardocamiloconsir

This comment was marked as resolved.

ricardocamiloconsir

This comment was marked as resolved.

ricardocamiloconsir

This comment was marked as resolved.

… type)

- Finding 1: Extract _get_session_model_pool() helper in auxiliary_client.py
  with module-level cache, replacing 2 inline lazy imports
- Finding 2: Change aux blocked return from {"error": ...} dict to None —
  callers expect OpenAI response object, not a plain dict
- Finding 5: Fix test session_slots type from List[str] to Dict[str, float]
  in 3 TestPoolModelEntry tests (was masked by len() working on both)

44/44 tests passing.
@ricardocamiloconsir

This comment was marked as resolved.

Critical fixes (Claude + Qwen consensus):
- Sentinel (_UNSET) in _get_session_model_pool() prevents permanent None
  caching when pool is not yet initialized on first auxiliary call
- Always call acquire_session_slot() instead of local cache check —
  refreshes session timestamps (prevents premature eviction) and
  eliminates TOCTOU race condition in _resolve_session_agent_runtime

Medium fixes (all 3 reviewers):
- _release_pool_slot passes {} to singleton instead of _load_gateway_config()
  (avoids disk I/O on every session reset / model override)
- Priority clamped to 1-10 range in from_config() validation

Minor (Grok):
- Finally block reuses _pool ref instead of second _get_session_model_pool()
- Docstring corrected: '4 places' → '3 places'

New test: priority clamping (0→1, 99→10)

45/45 tests passing.
@ricardocamiloconsir

This comment was marked as resolved.

Critical fix:
- C2 (MinMax): Both paths of _on_model_selected now call mark_manual_override.
  Path 1 was missing the call, causing the pool to silently reassign models
  after manual /model override.

Logging improvements (Kimi + MinMax consensus):
- mark_manual_override: except Exception:pass → logger.debug (both paths)
- Finally block in call_llm: except Exception:pass → logger.error for
  resource leak detection if aux slot release fails
- acquire_session_slot: logger.info → logger.debug to prevent log flood
  in high-traffic gateways

Cleanup:
- Removed reconstruct_from_active_sessions() — no callers, param unused
- Removed corresponding test
- Added TODO on async_call_llm documenting known limitation

44/44 tests passing.
@ricardocamiloconsir

This comment was marked as resolved.

@ricardocamiloconsir

This comment was marked as resolved.

ricardocamiloconsir

This comment was marked as resolved.

DRY: Extract _mark_pool_override(session_key) to eliminate duplicate
mark_manual_override blocks in _on_model_selected (both paths now
call _release_pool_slot + _mark_pool_override).

Tests: Add priority tie-break by least-loaded and least-loaded tie-break
by priority tests to cover the tie-breaking logic in _pick_candidate.

46/46 tests passing.
@ricardocamiloconsir

This comment was marked as resolved.

ricardocamiloconsir

This comment was marked as resolved.

ricardocamiloconsir

This comment was marked as resolved.

…de not cleared

CRITICAL fixes:
- IndentationError in _mark_pool_override and _release_pool_slot:
  method bodies were at class level (4sp) instead of method level (8sp).
  This prevented the gateway module from importing entirely.
- Sentinel leak in _get_session_model_pool(): except block now sets
  _aux_pool_cache = None instead of leaving it as _UNSET (truthy),
  which would cause AttributeError on .enabled access.

MEDIUM fix:
- Session reset (/new) now clears manual_override in the pool so the
  pool can reassign a model on the next turn. Previously, /model
  followed by /new left the session permanently marked as overridden.

46/46 tests passing. gateway/run.py parses cleanly.
@ricardocamiloconsir

This comment was marked as resolved.

Warning previously said 'Only the last entry will be used' but the code
retains both entries and acquire uses the first match. Updated to
accurately reflect the actual behavior.

46/46 tests passing.
@ricardocamiloconsir

This comment was marked as resolved.

…view)

H2+H3: Deduplicate pool_key in from_config() — keeps last entry, discards
earlier duplicates with warning. Prevents auxiliary_count going negative.

M1: Added _entries_by_key dict for O(1) pool_key lookups. Converted 6
lookup sites (acquire/release session, acquire/release auxiliary,
_aux_count, _reserved_count) from O(n) linear scan to dict.get().

M4: _mark_pool_override and clear_manual_override now use {} (singleton
ignore) instead of _load_gateway_config(), consistent with _release_pool_slot.

M3: Documented return None in call_llm docstring.

H1: Created issue NousResearch#37744 for async_call_llm integration, linked in TODO.

46/46 tests passing.
@ricardocamiloconsir

This comment has been minimized.

@rafaumeu rafaumeu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review — PR #37519: Session Model Pool

Veredito: APPROVE com 1 HIGH + 4 MEDIUM + 3 LOW


HIGH

H1 — async_call_llm sem integracao com pool
gateway/auxiliary_client.py tem TODO referenciando issue #37744, mas esse issue nao existe. O path async bypassa todo o pool (round-robin, aux count, override, health check). Se alguem chamar async antes do pool ser integrado, vai direto num provider aleatorio.

  • Acao: Criar o issue #37744 e linkar no TODO, ou remover a referencia.

MEDIUM

M1 — Config e runtime state na mesma dataclass
PoolModelEntry mistura campos de config (provider_config, pool_key) com runtime state (_failure_count, _last_failure). Qualquer teste que instancia PoolModelEntry precisa mockar ambos os mundos. Separar em PoolModelConfig (frozen) + runtime state facilita testes e evita mutabilidade acidental.

  • Acao: Considerar PoolModelConfig frozen dataclass + runtime wrapper.

M2 — call_llm retorna None quando slot auxiliar bloqueado
Quando _max_auxiliary_count e atingido, call_llm retorna None. Todos os callers esperam Response. Isso pode causar AttributeError downstream se o caller nao verificar.

  • Acao: Documentar o contrato (return type Response | None) ou levantar exception dedicada.

M3 — Inconsistencia de config loading
_mark_pool_override le config do disco (self._load_config()), enquanto _resolve_session_agent_runtime usa um singleton {} como fallback. Dois caminhos diferentes pro mesmo dado.

  • Acao: Unificar em um unico config loader.

M4 — _manual_overrides: set sem type hint generico
Deveria ser set[str] para consistencia com o resto do codebase tipado.

  • Acao: _manual_overrides: set[str] = set().

LOW

L1 — time.sleep(0.5) em tests
Pode causar flakiness em CI lento. Considerar mockar ou usar timeout maior.

  • Acao: Avaliar se time.sleep pode ser substituido por asyncio.sleep + mock.

L2 — Round-robin e semanticamente LRU
O nome round_robin confunde porque a implementacao e mais proxima de LRU (reordena no acesso). Isso pode enganar quem le o codigo.

  • Acao: Renomear ou documentar a semantica.

L3 — _aux_pool_cache nunca resetado
O cache de pool auxiliar cresce indefinidamente. Em sessoes longas, pode consumir memoria.

  • Acao: Adicionar TTL ou limite de tamanho.

Positivos

  • Thread safety robusta (locks bem colocados, sentinel pattern elegante)
  • Config validation completa com erros claros
  • 46 testes solidos cobrindo casos de borda
  • Integration points bem isolados (pool nao vaza pro gateway)
  • Commit history transparente e incremental

Review por @rafaumeu via Hermes Agent

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.

All issues from previous CHANGES_REQUESTED reviews have been addressed in subsequent commits. All 9 inline comments resolved.

…, async gap

- _get_session_model_pool(): only cache non-None results; retry on
  None (disabled) and import failures instead of permanently caching
- call_llm(): raise RuntimeError instead of returning None when pool
  blocks auxiliary call — prevents AttributeError in callers (vision,
  session_search, curator)
- async_call_llm(): add SessionModelPool auxiliary slot tracking
  (acquire before call, release in finally), removing the TODO gap
  where async calls bypassed pool throttling entirely

@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 detailed pool implementation and the follow-up fixes. The feature premise is still present on current main, but this needs rework before it can be safely salvaged.

Problems

  • agent/auxiliary_client.py:571 returns the _UNSET sentinel when no pool is configured. The sync and async callers then access _pool.enabled outside the guarded acquisition block (agent/auxiliary_client.py:5017 and 5495 on the PR head). Since session_model_pool.enabled defaults to false, this breaks normal auxiliary calls rather than becoming a no-op.
  • The PR resolver predates current routing precedence. Current main applies channel_overrides in gateway/run.py:3811-3844, whereas the PR applies its pool provider at gateway/run.py:2658. Salvage must not let a pool overwrite an explicit channel provider or bypass persisted /model restoration.

Suggested changes

  • Return None rather than _UNSET from the auxiliary helper when no enabled pool is available; add sync and async disabled-pool regressions.
  • Integrate allocation into current main's resolver after preserving /model and channel-override precedence, with focused gateway routing tests.

Automated hermes-sweeper review.

Comment thread agent/auxiliary_client.py
except Exception:
# Import failure is NOT cached — next call retries.
pass
return _aux_pool_cache

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.

When no pool is configured, _aux_pool_cache remains _UNSET, so this returns a truthy sentinel rather than the documented None. The callers then evaluate _pool.enabled outside their acquisition try blocks (lines 5017 and 5495), breaking auxiliary calls under the default-disabled configuration. Return None here when the cache is still _UNSET, and add disabled-pool sync/async coverage.

Comment thread gateway/run.py
# pool wants to assign a different model. Pool assignments are
# weaker than manual /model overrides and are released when the
# session ends or when a manual override takes effect.
if not override and resolved_session_key:

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.

Current main resolves channel_overrides after the global runtime and documents /model → channel_overrides → global precedence at gateway/run.py:3750-3844. When salvaging this allocation, apply it only below both explicit override layers; otherwise the later pool provider write can pair a channel-selected model with the pool provider.

@DeamonDev888

Copy link
Copy Markdown

Complementary approach to #62467. This PR handles session-level model+pool assignment, while #62467 handles the credential resolution layer underneath.

What this PR does: auto-assigns models to sessions with concurrency-aware pool slot tracking. This is orthogonal to credential resolution — it decides WHICH pool entry a session uses, not HOW that entry's base_url is resolved.

How they stack:

Both are needed. This PR's session-level assignment would consume the ResolvedCredential returned by #62467's unified resolver. If this PR's auxiliary slot tracking reads entry.base_url, it will automatically benefit from #62467's fixes (proper endpoint per entry, no stale cache, config override when pool_url matches registry default).

No conflict — these two PRs are complementary layers.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
@alt-glitch alt-glitch added comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard and removed sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) labels Jul 13, 2026
@teknium1 teknium1 added the area/sessions Session lifecycle, resume, persistence, history label Jul 19, 2026
@alt-glitch alt-glitch added the needs-decision Awaiting maintainer decision before any implementation label Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history 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 needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Session model pool — concurrency-aware auto-assignment with auxiliary slot tracking

6 participants