feat(backend): multi-role BackendPool + per-chat overrides + /model integration + openai-agents MCP fixes - #211
Merged
Merged
Conversation
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>
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>
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>
4 tasks
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>
15 tasks
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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Single PR for the multi-backend work β combines what was originally split across #211, #215, #216. Three layers stacked into one:
BackendPoolwith 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)./modelplumbing that resolves the per-chat backend correctly + OpenRouter discovery awaitability (originally fix(model-menu): resolve per-chat backend so /model honours overridesΒ #215).Architecture
src/core/backend-pool.tschat,heartbeat,dream(plus optional per-chat overrides)initBackendPool(config)initialises all configured roles with partial-init rollback on failurerebindRole(role, id)/rebindChat(chatId, id)/releaseChat(chatId)β atomic rebindingConsumer side
dispatcher.tsinitDispatcher({ backend })initDispatcher({ getBackend: () => ... })dream.tsinitDream({ backend })initDream({ getBackend: () => ... })heartbeat.tsinitHeartbeat({ backend })initHeartbeat({ getBackend: () => ... })gateway.backendHot-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)/modelmenu β per-chat-awarecore/backend-controller.tsβ newresolveChatBackend(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/modelpath re-renders against the per-chat backend, including post-rebind catalog refresh.frontend/discord/{commands,callbacks}.tsβ same treatment for/model,/settings,/status, warmSession./backendslash 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 theAgent. SDK namespaces every MCP tool asmcp_<serverName>__<toolName>so collisions (cancel_scheduledin telegram-tools + email-tools,list_serversin 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 thetoolFilter/ownerByToolplumbing β redundant now.openai-agents MCP β per-chat persistent bundle pool
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'sconnectMcpServershelper (parallel connect, per-server 10s timeout,failed/errorstracking,dropFailed: trueso one bad plugin can't kill the bundle).MCPServerStdiois constructed withcacheToolsList: trueβ tool list fetched once per server-lifetime, not every turn.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.getOrCreateBundle(...)replaces the per-turnbuildOpenAIAgentsMcpServers(...)call.mcpBundle.close()sites removed β bundle persists across turns, retries, model fallbacks, and errors. MCP servers are stateless wrt the model conversation.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
/modelshows the OpenRouter catalog (350+ models, grouped by provider)/modelafter a backend switch waits (up to 3s) for the catalog fetch rather than rendering emptyRequest timed outracesWhat it doesn't do
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
backend-pool.test.ts+ override + menu suites (pool work)/modelwork)openai-agents-mcp-pool.test.ts(subprocess + plugin map + SDK all mocked β cache reuse, per-chat isolation, in-flight dedup, release lifecycle,cacheToolsList: trueinvariant, etc.)openai-agents-enrichment.test.tsimport updated;openai-agents-models.test.tsgetSettingsPresentationcalls nowawait-edTest plan
npm run typecheckcleannpm testpasses/modelmenu shows Backend section when β₯2 backends enabledheartbeatBackend/dreamBackendconfig fields route correctlyenabledBackends: ["claude", "openai-agents"]withopenaiBaseUrl: "https://openrouter.ai/api/v1", switch backend on a chat,/modelshows OpenRouter catalog + Free toggle; switch back to Claude β Free toggle disappears, Claude models shownRequest timed outerrorsπ€ Generated with Claude Code