Skip to content

feat(backend): multi-role BackendPool + per-chat overrides + /model integration + openai-agents MCP fixes - #211

Merged
dylanneve1 merged 10 commits into
mainfrom
feat/multi-backend-hotswap
May 19, 2026
Merged

feat(backend): multi-role BackendPool + per-chat overrides + /model integration + openai-agents MCP fixes#211
dylanneve1 merged 10 commits into
mainfrom
feat/multi-backend-hotswap

Conversation

@claudiusthebot

@claudiusthebot claudiusthebot commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Single PR for the multi-backend work β€” combines what was originally split across #211, #215, #216. Three layers stacked into one:

  1. Refcounted BackendPool with per-role (chat / heartbeat / dream) and per-chat bindings (the original feat(backend): multi-role BackendPool + per-chat overrides + /model integration + openai-agents MCP fixesΒ #211).
  2. /model plumbing that resolves the per-chat backend correctly + OpenRouter discovery awaitability (originally fix(model-menu): resolve per-chat backend so /model honours overridesΒ #215).
  3. openai-agents MCP fixes β€” namespacing collisions away via SDK config + per-chat persistent MCP bundle pool (originally perf(openai-agents): per-chat persistent MCP bundle poolΒ #216).

Architecture

src/core/backend-pool.ts

  • Refcounted pool β€” multiple roles can share a single backend instance
  • Per-role holders: chat, heartbeat, dream (plus optional per-chat overrides)
  • initBackendPool(config) initialises all configured roles with partial-init rollback on failure
  • rebindRole(role, id) / rebindChat(chatId, id) / releaseChat(chatId) β€” atomic rebinding
  • Shared-instance reuse: two roles pointing at the same backend id share one instance (no double-init cost)

Consumer side

Consumer Before After
dispatcher.ts initDispatcher({ backend }) initDispatcher({ getBackend: () => ... })
dream.ts initDream({ backend }) initDream({ getBackend: () => ... })
heartbeat.ts initHeartbeat({ backend }) initHeartbeat({ getBackend: () => ... })
gateway.backend set once at bootstrap subscribes to pool change events to stay fresh

Hot-path consumers call getBackend() per query/cycle so rebinds take effect on the next invocation with no restart.

Config fields

  • heartbeatBackend β€” backend id for heartbeat runs (defaults to chat backend)
  • dreamBackend β€” backend id for dream runs (defaults to chat backend)
  • enabledBackends β€” whitelist of backend ids allowed to activate (empty = all)
  • chatSettings.backend β€” per-chat backend override (persisted in chat-settings store)

/model menu β€” per-chat-aware

  • core/backend-controller.ts β€” new resolveChatBackend(chatId, fallback) helper. Pool-first / gateway-fallback / null. Frontend-agnostic.
  • frontend/telegram/model-menu.ts (NEW, ~260 LOC) β€” controller consolidating main-menu, browse, and backend-submenu flows. Always resolves per-chat backend.
  • frontend/telegram/{commands,callbacks}.ts β€” every /model path re-renders against the per-chat backend, including post-rebind catalog refresh.
  • frontend/discord/{commands,callbacks}.ts β€” same treatment for /model, /settings, /status, warmSession.
  • Backend submenu appears when β‰₯2 backends enabled; "Reset to default backend" row removes the override. Absorbed the /backend slash command.

OpenRouter discovery awaitability

  • backend/openai-agents/discovery.ts (NEW, ~248 LOC) β€” extracted endpoint-model discovery with awaitable in-flight promise. Picker awaits up to 3s on first render so the catalog isn't empty just because the HTTP call hasn't returned. Free-pricing detection is now string-or-number tolerant.

openai-agents MCP β€” collision handling

  • mcpConfig: { includeServerInToolNames: true } on the Agent. SDK namespaces every MCP tool as mcp_<serverName>__<toolName> so collisions (cancel_scheduled in telegram-tools + email-tools, list_servers in ssh-tools + hetzner-tools) are impossible by construction. Both tools stay reachable β€” the prior toolFilter first-claimer approach silently dropped the loser. Built-ins (Read/Write/Edit/Bash/Glob/Grep) stay unprefixed via the SDK's reserved-names mechanism. Removed the toolFilter / ownerByTool plumbing β€” redundant now.

openai-agents MCP β€” per-chat persistent bundle pool

  • New module src/backend/openai-agents/mcp-pool.ts:
    • Map<chatId, OpenAIAgentsMcpBundle> caches one bundle per chat.
    • getOrCreateBundle(args) returns the cached bundle or builds + connects fresh, using SDK's connectMcpServers helper (parallel connect, per-server 10s timeout, failed/errors tracking, dropFailed: true so one bad plugin can't kill the bundle).
    • Every MCPServerStdio is constructed with cacheToolsList: true β€” tool list fetched once per server-lifetime, not every turn.
    • In-flight build deduping: concurrent gets for the same chat share one build promise; different chats build in parallel.
    • releaseBundle(chatId) closes subprocesses + drops cache entry. Safe against in-flight builds β€” awaits then closes.
    • releaseAllBundles() for backend factory cleanup so unbinding openai-agents leaves no orphan subprocesses.
  • Handler:
    • getOrCreateBundle(...) replaces the per-turn buildOpenAIAgentsMcpServers(...) call.
    • All four mcpBundle.close() sites removed β€” bundle persists across turns, retries, model fallbacks, and errors. MCP servers are stateless wrt the model conversation.
  • Factory cleanup awaits releaseAllBundles() before resetting state.

Why this matters: original handler spawned ~15 subprocesses per chat per turn. On a small VPS that added 1-10s setup latency, intermittently raced as MCP error -32001: Request timed out, and re-listed every tool definition on every turn. Claude SDK keeps its MCP set alive across queries β€” this brings openai-agents to parity.

Removed mcp.ts (the original per-turn builder) β€” fully superseded by the pool. Barrel updated to re-export the pool surface.

Result

  • Backend submenu β†’ OpenAI Agents β†’ /model shows the OpenRouter catalog (350+ models, grouped by provider)
  • Free-only toggle appears only when the per-chat backend reports free models
  • First /model after a backend switch waits (up to 3s) for the catalog fetch rather than rendering empty
  • Per-chat backend override persists across restarts
  • MCP tool-name collisions no longer crash openai-agents turns
  • openai-agents MCP subprocess set persists across turns β€” no more per-turn 15-spawn fan-out or Request timed out races

What it doesn't do

  • 66k tool-definition context bloat is NOT addressed. The SDK's blessed answer (toolSearchTool() + deferLoading: true) is OpenAI Responses API only β€” doesn't apply to OpenRouter / Chat Completions. Realistic future fixes are per-chat plugin allowlists or a Talon-side meta-tool for deferred discovery. Out of scope here.

Test coverage

  • 29 tests in backend-pool.test.ts + override + menu suites (pool work)
  • 22 discovery tests + 17 model-menu-controller tests (/model work)
  • 14 tests in openai-agents-mcp-pool.test.ts (subprocess + plugin map + SDK all mocked β€” cache reuse, per-chat isolation, in-flight dedup, release lifecycle, cacheToolsList: true invariant, etc.)
  • openai-agents-enrichment.test.ts import updated; openai-agents-models.test.ts getSettingsPresentation calls now await-ed
  • TypeScript clean, prettier clean, oxlint 12 warnings (down from 13 baseline)

Test plan

  • npm run typecheck clean
  • npm test passes
  • /model menu shows Backend section when β‰₯2 backends enabled
  • Per-chat backend override persists across Talon restarts
  • heartbeatBackend / dreamBackend config fields route correctly
  • Shared-instance reuse: two roles same id β†’ one init call, one cleanup call
  • Live smoke: enabledBackends: ["claude", "openai-agents"] with openaiBaseUrl: "https://openrouter.ai/api/v1", switch backend on a chat, /model shows OpenRouter catalog + Free toggle; switch back to Claude β†’ Free toggle disappears, Claude models shown
  • Live smoke: bind a chat to openai-agents, send 3+ messages back-to-back, confirm only one MCP subprocess set is spawned (not 3x), no Request timed out errors

πŸ€– Generated with Claude Code

Introduces a backend controller that owns the currently-active
backend and brokers atomic hot-swaps between any registered backend
(claude / opencode / kilo / codex / openai-agents). Before this
change, switching backends required editing config.json and
restarting Talon.

## What's new

- **`src/core/backend-controller.ts`** β€” single source of truth for
  the active backend. Exposes `getActiveBackend()`, `switchBackend()`,
  `listAvailableBackends()`, `onBackendChange()`. Init-before-cleanup
  ordering so failed inits leave the previous backend in place.
- **`/backend` slash command** (admin-gated) β€” no-arg form lists
  registered backends and highlights the active one; arg form
  attempts a hot-swap, persists `config.backend` to ~/.talon/config.json,
  and resets the active chat session.
- **Listener pattern** β€” `onBackendChange(cb)` lets cached-reference
  holders (gateway, integration tests, observability hooks) refresh
  on swap. Hot-path consumers (dispatcher / dream / heartbeat) read
  through `getActiveBackend()` each call and don't need to subscribe.

## Refactors

- **Dispatcher**: `initDispatcher({ backend })` β†’ `initDispatcher({ getBackend })`.
  Backend resolved per call so a swap takes effect on the very next query.
- **Dream / Heartbeat**: `initDream({ backend })` /
  `initHeartbeat({ backend })` β†’ `getBackend` provider. Same propagation
  story β€” next cycle picks up the new backend.
- **Gateway**: `gateway.backend` slot preserved for direct read sites
  (commands, shared-action dispatch). Bootstrap subscribes to
  `onBackendChange` to keep the slot fresh on swap.

## Test coverage

- 17 new controller tests (`backend-controller.test.ts`): init,
  hot-swap, same-id rejection, unknown-id rejection, init-failure
  rollback, cleanup-error tolerance, listener notification, listener
  errors don't block siblings, unsubscribe, round-trip swap (catches
  cleanup leaks across 4-cycle init/cleanup pairs), cleanup
  idempotency, lazy ordering (new backend visible before old cleanup
  resolves).
- All 5 touched test suites (dispatcher, dream, heartbeat,
  integration, backend-controller) green: 91/91.
- Full suite: 2533 passing / 12 skipped / 0 failing (excluding the
  pre-existing flaky opencode-real-bootstrap live tier).
- tsc clean, lint 0 errors (13 pre-existing warnings unchanged).

## Out of scope (follow-up PRs)

- Per-chat backend overrides (`chatSettings.backend`) β€” global swap only for now.
- `/model` menu integration (backend section + backend-aware model picker).
- Eager-init mode (all backends warm at startup) β€” currently lazy-init only.
- Telegram callback handlers for backend selection from settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude added 4 commits May 18, 2026 21:39
Replaces the single-backend controller introduced in 911ee37 with a
refcounted pool of backend instances and per-role bindings. Same
branch / same PR β€” addresses two structural problems with the
single-active model raised in review:

  1. **Heartbeat β‰  chat backend.** Single-active forced every role
     (chat / heartbeat / dream) onto the same backend. Now each role
     binds independently β€” typical post-Anthropic-metering setup is
     chat on a cheap backend, heartbeats on Claude Sonnet for quality,
     dream wherever's convenient.
  2. **Hot-swap was the wrong primitive.** Concurrent backends serving
     different roles is what we actually want. Pool refcounts instances:
     two roles on the same id share one instance, an orphaned id
     (refcount β†’ 0) triggers cleanup.

## Public API (new)

- `initBackendPool(config, ctx)` β€” binds chat / heartbeat / dream from
  `config.backend`, `config.heartbeatBackend`, `config.dreamBackend`.
  Identical ids share a pool entry. Failed init rolls back partial
  state β€” no leaked instances past bootstrap.
- `getBackendForRole(role)` / `getBackendIdForRole(role)` /
  `getBackendLabelForRole(role)` β€” per-role accessors.
- `rebindRole(role, id, config)` β€” atomic per-role swap. Reuses pool
  instance if `id` is already pooled (no double init); decrements
  previous binding's refcount; cleanup fires only when last role
  releases.
- `getPoolSnapshot()` β€” live pool + bindings for `/backend` rendering.
- `onBackendChange((role, backend, info) => ...)` β€” listener signature
  gains the role argument so consumers can filter (gateway only cares
  about chat-role rebinds).
- `cleanupBackendPool()` β€” shutdown teardown of every entry.

## Config schema

- New optional `heartbeatBackend` and `dreamBackend` fields. Both
  default to `backend` when unset. Models stay backend-relative β€”
  `heartbeatModel` is resolved against `heartbeatBackend`'s catalog.

## /backend command

Extended for the per-role model:

  /backend                      pool snapshot + available backends
  /backend <id>                 rebind chat (shorthand)
  /backend <role>               show binding for role
  /backend <role> <id>          rebind specific role

Persists role-specific config field on success (`config.backend` /
`config.heartbeatBackend` / `config.dreamBackend`). Chat rebinds reset
the chat session; heartbeat/dream rebinds don't touch the live chat.

## Legacy aliases (preserved)

`initBackendController` / `getActiveBackend` / `switchBackend` /
`cleanupBackendController` still work β€” they route to the chat role.
Tests + integration paths that pre-date the pool stay green.

## Test coverage

- **12 new pool tests** in `backend-pool.test.ts`:
  - Single instance reused across all 3 roles (refcount = 3)
  - Two instances when chat and heartbeat differ
  - Rebind to pooled id reuses without re-init
  - Rebind that orphans triggers cleanup
  - Same-id rebind rejected
  - Unknown-id rebind rejected with helpful error
  - Failed init keeps current binding in place
  - `initBackendPool` partial-init rollback (cleans up succeeded roles)
  - Listener receives role argument
  - Role accessors throw when role not bound
  - Cleanup is idempotent
  - Round-trip rebind tracks init/cleanup ordering across 4 cycles
- All 17 existing controller tests still pass (legacy aliases route to chat role).
- Full suite: **2545 passing / 12 skipped / 0 failing** (excluding the
  pre-existing flaky `opencode-real-bootstrap` live tier).
- tsc clean, lint 0 errors (13 pre-existing warnings unchanged).

## Bootstrap wiring

- `src/bootstrap.ts` calls `initBackendPool(config, ctx)` once, then
  hands role-specific accessors to dispatcher / dream / heartbeat:
  `() => getBackendForRole("chat" | "dream" | "heartbeat")`.
- `src/index.ts` / `src/cli.ts` subscribe to `onBackendChange` and
  filter on `role === "chat"` to keep `gateway.backend` in sync β€”
  heartbeat / dream rebinds don't touch the gateway.

## Out of scope (next PRs)

- Per-chat backend overrides (`chatSettings.backend`) β€” global per-role
  bindings only for now.
- `/model` menu integration: when changing model picks a model from a
  different backend, rebind the chat role atomically.
- Backend-qualified model strings (`"claude:claude-opus-4-7"`) β€” currently
  models are role-relative, paired with the role's backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…egration

Continues the same PR. Three changes from the previous round:

  1. Per-chat backend override. Each chat can pin its own backend
     independently β€” Pandario on Claude while DMs run on OpenAI
     Agents, both alive concurrently. The pool refcounts instances
     so the two backends stay warm in parallel.
  2. `enabledBackends` config field. Optional whitelist that filters
     the `/model` backend picker. Unset β†’ every registered backend
     appears; set β†’ only the listed ids.
  3. Folded into `/model`. The standalone `/backend` slash command
     is removed; the model menu gains a "Backend: <label>" button
     that opens a submenu listing enabled backends, with a "Reset
     to default backend" row when overridden.

## Pool generalisation

Internal model graduates from fixed `BackendRole` to opaque holder
strings:

  - `role:chat`, `role:heartbeat`, `role:dream` β€” global role holders
  - `chat:<chatId>` β€” per-chat override holders

Typed wrappers (`rebindRole`, `rebindChat`, `releaseChat`,
`getBackendForChat`, `getBackendIdForChat`, `hasChatBackendOverride`)
hide the holder strings from callers. Legacy single-active aliases
(`initBackendController`, `getActiveBackend`, `switchBackend`, etc.)
still route to the chat role for backward compat.

`listAvailableBackends(config)` now honours `config.enabledBackends`.

## Bootstrap

- `initBackendPool` unchanged; still binds the three role holders.
- New: bootstrap re-acquires every persisted per-chat override at
  startup (via `getAllChatSettings`) so chats resume on their
  override backend without waiting for the user to re-pick.
- Dispatcher's `getBackend` signature widened to
  `(chatId?: string) => QueryBackend`. Bootstrap wires
  `getBackendForChat(chatId)` so per-chat overrides + role rebinds
  both propagate without dispatcher re-init.
- Gateway listener filters on `holder === roleHolder("chat")` so it
  ignores per-chat rebinds and heartbeat / dream rebinds (those run
  through their own `getBackend` providers; the gateway only caches
  the chat-role default for `/model` and shared-action dispatch).

## /model menu

- "Backend: <label>" button (shown when β‰₯2 backends are enabled).
- New backend submenu: lists enabled backends with active marked,
  "Reset to default backend" row when overridden, "← Back to /model".
- Selecting a different backend: rebinds the per-chat holder,
  persists `chatSettings.backend`, resets the chat session +
  history + pulse checkpoint, clears `chatSettings.model` so the
  new backend's default model takes effect.
- Selecting "Reset to default backend": releases the holder, drops
  `chatSettings.backend`, same session-reset bookkeeping.
- Existing flows (browse, free-toggle, reset, select) unchanged.

## Config

- New `enabledBackends?: BackendId[]` β€” UX whitelist for the picker.
- New `chatSettings.backend?: string` β€” per-chat override storage.
- New `setChatBackend(cid, id|undefined)` setter; pair with
  `rebindChat` / `releaseChat` for the live pool side.

## Test coverage

- 3 new pool tests (per-chat overrides):
  - Override shares pool with role bindings, releaseChat cleans up
  - Two chats on two different backends both alive concurrently
  - releaseChat on an unbound chat is a no-op
- 17 existing controller tests still green (legacy aliases preserved)
- 12 existing pool tests still green (listener signature updated to
  receive holder string instead of bare role name β€” same semantic)
- `telegram-model-menu.test.ts` updated for new menu state fields
  (activeBackend / hasBackendOverride / showBackendButton)
- Full suite: **2548 passing / 12 skipped / 0 failing** (excluding
  pre-existing flaky `opencode-real-bootstrap` live tier)
- tsc clean, lint 0 errors (13 pre-existing warnings unchanged)

## Removals

- `/backend` slash command β€” replaced by the `/model` submenu
- `/help` entry for `/backend`
- Direct imports of `switchBackend`/`getActiveBackendId`/
  `getActiveBackendLabel`/`rebindRole`/`getPoolSnapshot` from
  `commands.ts` (the controller is now consumed entirely through
  the chat-scoped helpers and the menu submenu)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claudiusthebot claudiusthebot changed the title feat(backend): hot-swap backends at runtime via /backend slash command feat(backend): multi-role BackendPool + per-chat overrides + /model integration May 18, 2026
claude added 2 commits May 19, 2026 07:15
The /model command and its callbacks read `gateway.backend` β€” the
global chat-role backend β€” which broke two things once per-chat
backend overrides shipped via the pool refactor:

  1. A chat that switched to `openai-agents` to use OpenRouter saw
     the *Claude* catalog in /model instead of OpenRouter's, because
     the menu was always rendered against the global default.
  2. The free-only toggle, gated on `freeCount > 0`, never appeared
     because the global default's catalog was queried β€” Claude has no
     free models, so `freeCount` was always 0.

Plus `fetchEndpointModels` was fire-and-forget, so the very first
/model render after a backend switch could race the `/models` HTTP
call and show an empty catalog before the network returned.

Changes
───────

* `src/core/backend-controller.ts` β€” add `resolveChatBackend(chatId,
  fallback)`: pool-first / gateway-fallback / `null` when neither is
  wired. Frontend-agnostic; replaces the ad-hoc `gateway?.backend`
  reads everywhere they used to live.
* `src/frontend/telegram/model-menu.ts` (NEW, 260 LOC) β€” controller
  that consolidates the /model menu, browse, and backend-submenu
  flows. Always resolves the per-chat backend via the new core
  helper. Pure functions; no Telegram types leak in.
* `src/frontend/telegram/commands.ts` β€” `/model`, `/reset` warmSession,
  and `/status` enrichment all switch to per-chat backend.
* `src/frontend/telegram/callbacks.ts` β€” every `/model` callback
  branch (select, browse, nav, backend submenu) re-renders against
  the per-chat backend. Specifically: after a `backend-select` rebind,
  the menu re-render correctly uses the *new* backend's catalog
  instead of the stale closure-captured one.
* `src/frontend/discord/{commands,callbacks}.ts` β€” same treatment for
  /model, /settings, /status, and warmSession.
* `src/backend/openai-agents/discovery.ts` (NEW, 248 LOC) β€” extracted
  endpoint-model discovery. `startDiscovery` stashes the in-flight
  promise on state; `awaitDiscovery(timeoutMs)` lets the picker wait
  briefly on first render so the catalog isn't empty just because
  the HTTP call hasn't returned. `refreshDiscovery` for explicit
  retries. Free-pricing detection is now string-or-number tolerant
  (vLLM ships numeric 0, OpenRouter ships string "0").
* `src/backend/openai-agents/init.ts` β€” delegates fetch to
  `discovery.ts`; caches `baseURL`/`apiKey` on state so
  `triggerDiscoveryRefresh` can retry without re-reading config.
* `src/backend/openai-agents/state.ts` β€” adds `discoveryPromise`,
  `discoveryAt`, `baseURL`, `apiKey` fields.
* `src/backend/openai-agents/models.ts` β€” `getSettingsPresentation`
  awaits in-flight discovery before snapshotting.

Tests
─────

* `openai-agents-discovery.test.ts` (NEW, 22 cases) β€” discovery
  lifecycle, soft-timeout, idempotence, free-pricing variants.
* `telegram-model-menu-controller.test.ts` (NEW, 17 cases) β€” per-chat
  backend resolution, free-toggle gating off the per-chat catalog,
  filter/page/provider propagation, override <-> default round trip.
* `openai-agents-enrichment.test.ts` β€” import path updated to point
  at the new discovery module.
* `openai-agents-models.test.ts` β€” `getSettingsPresentation` calls
  now `await`-ed (it's async; previously sync-wrapped in Promise.resolve).

2587 tests pass (109 in the touched areas), TypeScript clean,
prettier clean, no new oxlint warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenAI Agents SDK throws `Duplicate tool names found across MCP
servers: cancel_scheduled` (and similar) when two MCP servers
expose the same tool name. Talon legitimately ships colliding
names β€” Telegram frontend's `cancel_scheduled` (cancel a scheduled
message) and the email plugin's `cancel_scheduled` (cancel a
scheduled email) β€” because they're scoped to different domains.

Fix: install a shared first-claimer `toolFilter` on every
MCPServerStdio. Iteration order in `mcpServers` determines
priority β€” frontend tools first, then Brave Search, then plugins.
Whichever server lists a colliding tool name first keeps it;
subsequent servers have that name silently dropped from their
visible toolset.

Closure-owned Map persists for the bundle's lifetime, so a
re-`listTools()` re-applies the same ownership idempotently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claudiusthebot claudiusthebot changed the title feat(backend): multi-role BackendPool + per-chat overrides + /model integration feat(backend): multi-role BackendPool + per-chat overrides + /model integration (incl. PR #215) May 19, 2026
The OpenAI Agents SDK throws `Duplicate tool names found across MCP
servers: <name>` when two MCP servers expose the same tool name.
Talon legitimately ships colliding names β€” Telegram's
`cancel_scheduled` (scheduled message) vs the email plugin's
`cancel_scheduled` (scheduled email) β€” because they're scoped to
different domains.

Previous approach (callable `toolFilter` with closure-shared
`ownerByTool` map) used a first-claimer rule: whichever server
registered the name first kept it, subsequent servers had that name
silently dropped from their visible toolset. That avoided the SDK
error but cost the model access to the dropped tool β€” e.g. when on
the openai-agents backend, the model could no longer cancel
scheduled emails because Telegram had already claimed
`cancel_scheduled`.

Fix: pass `mcpConfig: { includeServerInToolNames: true }` to the
`Agent` constructor. The SDK then namespaces every MCP-sourced tool
as `mcp_<serverName>__<toolName>`, so collisions are impossible by
construction:

  - mcp_telegram-tools__cancel_scheduled
  - mcp_email-tools__cancel_scheduled

Both tools stay reachable. Built-in tools (Read/Write/Edit/Bash/
Glob/Grep) are NOT prefixed β€” the SDK's `getMcpToolReservedNames`
mechanism reserves agent-provided tool names. System-prompt suffix
updated to document the namespacing convention so the model uses
the prefixed names verbatim from the available-tools list.

The `toolFilter` plumbing in `mcp.ts` (closure-shared `ownerByTool`
map, `makeDedupFilter` factory, per-server filter assignment) is
removed β€” it's now redundant.

Tests: 93/93 openai-agents tests pass; 25/25
backend-controller/registry tests pass; tsc + prettier clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original openai-agents handler called `buildOpenAIAgentsMcpServers`
on every turn and closed every spawned subprocess in `finally`. With
~15 plugins active that means ~15 subprocess spawns per message per
chat, which on a small VPS:

  - adds 1–10s of cold-start latency to every turn,
  - intermittently races out as `MCP error -32001: Request timed out`
    when the spawn fan-out collides with another chat's MCP setup
    (PaweΕ‚'s message in prod died exactly this way),
  - wastes 15Γ— `cacheToolsList` opportunities β€” the SDK re-fetches
    every tool definition for every server on every turn.

The Claude SDK backend keeps its MCP subprocess set alive for the
lifetime of the per-chat session. This PR brings the same pattern to
openai-agents.

What landed
───────────

New module `src/backend/openai-agents/mcp-pool.ts`:

  - `Map<chatId, OpenAIAgentsMcpBundle>` caches one bundle per chat.
  - `getOrCreateBundle(args)` returns the cached bundle, or builds +
    connects a fresh one. Uses the SDK's `connectMcpServers` helper
    (parallel connect, per-server timeout, `failed`/`errors` tracking,
    `dropFailed: true` so one bad plugin can't take down the rest).
  - Every `MCPServerStdio` is constructed with `cacheToolsList: true`
    so the SDK lists tools exactly once per server-lifetime instead of
    on every turn.
  - In-flight build deduping: concurrent gets for the same chat share
    one build promise; concurrent gets for different chats build in
    parallel.
  - `releaseBundle(chatId)` closes subprocesses + drops the cache
    entry (for chat rebind, /reset, chat destruction). Safe against an
    in-flight build β€” awaits then closes.
  - `releaseAllBundles()` for the backend factory's cleanup hook so
    unbinding openai-agents leaves no orphan MCP subprocesses.
  - `getActiveBundleIds()` for diagnostics.

Handler:

  - `getOrCreateBundle(...)` replaces the per-turn `buildOpenAIAgentsMcpServers(...)` call.
  - All four `mcpBundle.close()` sites removed β€” the bundle persists
    across turns, retries, model fallbacks, and errors. MCP servers
    are stateless wrt the model conversation; closing them on every
    error or recursive retry would defeat the cache.

Factory:

  - `cleanup` hook now awaits `releaseAllBundles()` before resetting
    state, so an `openai-agents β†’ claude` rebind doesn't leak
    subprocesses.

Tests (14 new):

  - First-get builds; cache reuse on subsequent gets.
  - Per-chat isolation (chat-A and chat-B build independently).
  - Concurrent-get dedup (one build covers N parallel callers).
  - Release closes + evicts; release-of-unknown-chat is a no-op.
  - Rebuild-after-release works.
  - `releaseAllBundles` closes every live bundle.
  - Release-while-build-in-flight waits then closes.
  - Every server is constructed with `cacheToolsList: true`.
  - `connectMcpServers` invoked with `connectInParallel: true`.
  - Bundle includes frontend + plugin + (optional) brave-search
    servers correctly.

Removed `mcp.ts` (the original per-turn builder) β€” fully superseded
by the pool. Barrel updated to re-export the pool surface.

Validation
──────────

  - 144/144 openai-agents + backend-* tests pass.
  - `tsc --noEmit` clean.
  - `prettier --check` clean.
  - `oxlint` 0 warnings / 0 errors.

Stacked on top of `feat/multi-backend-hotswap` (PR #211). Not a
behaviour change for any other backend β€” Claude SDK / Codex /
Kilo / OpenCode unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claudiusthebot claudiusthebot changed the title feat(backend): multi-role BackendPool + per-chat overrides + /model integration (incl. PR #215) feat(backend): multi-role BackendPool + per-chat overrides + /model integration + openai-agents MCP fixes May 19, 2026
Resolve conflicts from PR #208 (openai-agents MemorySession +
turn-memory persistence) landing on main under us.

- state.ts: keep BOTH the per-chat `sessions` map (PR #208) and the
  discovery promise tracking (PR #211).
- factory.ts: import `clearChatSession` (PR #208) AND `releaseAllBundles`
  (PR #211). Both lifecycles needed.
- handler.ts: preserve PR #211's mcpConfig.includeServerInToolNames
  documentation alongside PR #208's tool-list diagnostic. Agent
  constructor uses both `mcpConfig` and per-chat session.
- init.ts: take PR #211's discovery.ts extraction wholesale β€”
  inline helpers superseded by the module.
- discovery.ts: backfill PR #208 behavior β€” always store entries
  with valid `id` (bare-id ok), add `display_name` fallback (Gemini),
  add `normaliseModelId` (strip `models/` prefix from Gemini). Fixes
  enrichment test that expected sparse `/v1/models` entries to land
  in the catalog regardless of caps.
- frontend commands.ts (Discord + Telegram): merge per-chat backend
  resolution (PR #211) with `resetChat` invocation (PR #208) so
  `/reset` wipes the right backend's MemorySession before warming.
- test fixes: live-discovery test imports `fetchEndpointModels` from
  discovery.js (was init.js); models test `await`s the now-async
  `getSettingsPresentation` in 4 sites added by PR #208.

2541/2541 unit tests passing. Typecheck + prettier clean.
@dylanneve1
dylanneve1 enabled auto-merge (squash) May 19, 2026 13:27
@dylanneve1
dylanneve1 merged commit cb2a661 into main May 19, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants