feat: the assistant degrades politely under load — bounded retrieval queue, per-user limit, short search cache
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s

Three things the load path lacked. The retrieval slots had an unbounded
line behind them, so a burst meant silent waiting; past a bounded line, or
after eight seconds in it, a caller now gets 'the library is busy' and a
503 with a retry hint. The paid routes had no per-account ceiling; they
now get one, counted in Redis so every replica sees the same count and
nothing is refused when Redis is absent. And the same library search
asked twice within a minute (a retry, a refresh) went to the library
twice; it is now answered from Redis, with 0 turning that off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-14 07:56:26 +02:00
parent fbc6e3fa6c
commit 5d28ebad9c
13 changed files with 229 additions and 7 deletions

View file

@ -258,6 +258,10 @@ DB_PASSWORD=pedscribe_secret_change_me
# CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS=90000
# CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS=600000
# CLINICAL_ASSISTANT_MCP_CONCURRENCY=3 # library searches in flight at once; they used to run one at a time
# CLINICAL_ASSISTANT_MCP_QUEUE_MAX= # callers allowed to wait for a slot (default 4 x concurrency); past it: "busy, try again"
# CLINICAL_ASSISTANT_MCP_QUEUE_WAIT_MS=8000 # how long a caller waits in that line before being told the library is busy
# CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE=30 # paid assistant questions per user per minute, counted in Redis (no Redis: no limit)
# CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S=60 # same library search within this window is answered from Redis; 0 disables
# CLINICAL_ASSISTANT_MCP_WARMUP= # open a session at boot
# CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS=

View file

@ -0,0 +1,45 @@
// A per-user ceiling on the expensive routes, counted in Redis so every
// replica sees the same count. Fixed windows: the key carries the window's
// start, INCR counts, EXPIRE lets it fall away on its own.
//
// Without Redis the limiter lets everything through. A missing cache must
// never turn into a refused question; it only means one process's view.
var { getRedis } = require('../utils/redis');
function positiveInt(value, fallback) {
var n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
// rateLimit('clinical-chat', { limit: 30, windowSeconds: 60 })
function rateLimit(scope, opts) {
opts = opts || {};
var limit = positiveInt(opts.limit, 30);
var windowSeconds = positiveInt(opts.windowSeconds, 60);
return async function(req, res, next) {
var who = req.user && req.user.id;
if (!who) return next();
var redis;
try { redis = await getRedis(); } catch (e) { redis = null; }
if (!redis) return next();
var windowStart = Math.floor(Date.now() / 1000 / windowSeconds) * windowSeconds;
var key = 'rl:' + scope + ':' + who + ':' + windowStart;
var count;
try {
count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSeconds + 1);
} catch (e) {
return next();
}
if (count <= limit) return next();
var retryAfter = windowStart + windowSeconds - Math.floor(Date.now() / 1000);
res.set('Retry-After', String(Math.max(1, retryAfter)));
return res.status(429).json({
error: 'You have sent a lot of questions in the last minute. Please wait a moment and try again.',
code: 'RATE_LIMITED',
retryAfter: Math.max(1, retryAfter)
});
};
}
module.exports = { rateLimit };

View file

@ -10,6 +10,7 @@ var axios = require('axios');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { rateLimit } = require('../middleware/rateLimit');
var { callAI, callAIStream } = require('../utils/ai');
var generatedImages = require('../utils/generatedImages');
var imageTool = require('../utils/imageTool');
@ -51,6 +52,17 @@ var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts');
// router.use(authMiddleware) gates every /api path, including routes owned by
// routers mounted after it in server.js.
router.use('/clinical-assistant', authMiddleware);
// The paid routes: a question costs a library search and a model call, so
// one account gets a bounded number per minute. Counted in Redis, shared by
// every replica; without Redis nothing is refused.
function positiveIntEnv(name, fallback) {
var n = Number(process.env[name]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
var askLimit = rateLimit('clinical-ask', {
limit: positiveIntEnv('CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE', 30), windowSeconds: 60
});
router.post(['/clinical-assistant/chat', '/clinical-assistant/chat/stream', '/clinical-assistant/image', '/clinical-assistant/image/jobs'], askLimit);
var MAX_SAVED_CHATS_PER_USER = 100;
var MAX_SAVED_CHAT_TITLE = 160;
@ -559,6 +571,26 @@ router.get('/clinical-assistant/image/jobs/:id/download', async function(req, re
} catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); }
});
// The same question asked twice in a minute (a retry, a refresh, two people on
// the same case) is answered from Redis instead of the library. The index only
// changes when the admin re-runs it, so a short window loses nothing; 0 turns
// it off. Without Redis every search goes to the library, as before.
var RETRIEVAL_CACHE_TTL_S = positiveIntEnv('CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S', 60);
function retrievalCacheKey(query, opts) {
var hash = require('crypto').createHash('sha256').update(String(query)).digest('hex');
return 'retrieval:' + hash + ':' + opts.limit + ':' + (opts.includeContext ? 1 : 0) + ':' + opts.contextChars;
}
async function cachedSemanticSearch(query, opts, timing) {
var cache = redisCache && typeof redisCache.getJson === 'function' ? redisCache : null;
if (!RETRIEVAL_CACHE_TTL_S || !cache) return semanticSearch(query, opts);
var key = retrievalCacheKey(query, opts);
var hit = await cache.getJson(key).catch(function() { return null; });
if (hit) { if (timing) timing.searchCached = true; return hit; }
var response = await semanticSearch(query, opts);
cache.setJson(key, response, RETRIEVAL_CACHE_TTL_S).catch(function() {});
return response;
}
async function prepareAssistantChat(body) {
body = body || {};
var checked = checkConversation(body.history, body.message, await getConversationLimit());
@ -592,11 +624,11 @@ async function prepareAssistantChat(body) {
return message;
});
timing.rewriteMs = Date.now() - phase; phase = Date.now();
var searchResponse = await semanticSearch(searchQuery, {
var searchResponse = await cachedSemanticSearch(searchQuery, {
limit: searchLimit,
includeContext: includeContext,
contextChars: contextChars
});
}, timing);
timing.searchMs = Date.now() - phase;
// Retrieval is text-only. The multimodal path called nc_multimodal_search
// against a second hardcoded collection with an embedding service that was

View file

@ -22,6 +22,12 @@ var _mcpCallQueue = Promise.resolve();
// session, so a small bound is enough to stop the queueing without letting a
// burst pile onto it.
var MCP_CONCURRENCY = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3);
// The queue behind those slots is bounded too. Past MCP_QUEUE_MAX waiters, or
// after MCP_QUEUE_WAIT_MS in line, a caller is told the library is busy
// instead of holding a request open indefinitely: a burst then degrades into
// a few quick "try again" answers rather than a minute of silence for everyone.
var MCP_QUEUE_MAX = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_QUEUE_MAX, MCP_CONCURRENCY * 4);
var MCP_QUEUE_WAIT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_QUEUE_WAIT_MS, 8000);
var _inFlight = 0;
var _waiting = [];
// The session is renewed before it expires, in the background, so no one
@ -117,14 +123,36 @@ function warmMcpSession() {
return getMcpSession();
}
function busyError() {
var e = new Error('The library is busy right now. Please try again in a moment.');
e.statusCode = 503;
e.code = 'RETRIEVAL_BUSY';
e.retryAfterSeconds = 5;
return e;
}
function acquireSlot() {
if (_inFlight < MCP_CONCURRENCY) { _inFlight++; return Promise.resolve(); }
return new Promise(function(resolve) { _waiting.push(resolve); });
if (_waiting.length >= MCP_QUEUE_MAX) return Promise.reject(busyError());
return new Promise(function(resolve, reject) {
var waiter = { resolve: resolve, timer: null };
waiter.timer = setTimeout(function() {
var at = _waiting.indexOf(waiter);
if (at !== -1) _waiting.splice(at, 1);
reject(busyError());
}, MCP_QUEUE_WAIT_MS);
if (waiter.timer.unref) waiter.timer.unref();
_waiting.push(waiter);
});
}
function releaseSlot() {
var next = _waiting.shift();
if (next) next(); else _inFlight--;
if (next) { clearTimeout(next.timer); next.resolve(); } else _inFlight--;
}
function queueDepth() {
return { inFlight: _inFlight, waiting: _waiting.length, concurrency: MCP_CONCURRENCY, queueMax: MCP_QUEUE_MAX };
}
async function callMcpTool(name, args) {
@ -374,6 +402,7 @@ function parseMcpResponse(body) {
module.exports = {
semanticSearch: semanticSearch,
queueDepth: queueDepth,
indexedTopicSuggestions: indexedTopicSuggestions,
getMcpHealth: getMcpHealth,
warmMcpSession: warmMcpSession,

View file

@ -55,6 +55,7 @@ function server(options = {}) {
const mocks = {
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
'../middleware/auth': { authMiddleware() {} },
'../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } },
'./auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },

View file

@ -53,6 +53,7 @@ function server(options = {}) {
const mocks = {
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
'../middleware/auth': { authMiddleware() {} },
'../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } },
'./auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },

View file

@ -0,0 +1,68 @@
// A person gets a bounded number of paid questions a minute, counted in
// Redis; and the same library search twice in a minute is answered from
// Redis. Neither refuses anything when Redis is absent.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const Module = require('node:module');
function fakeRedis() {
const store = new Map();
return {
store,
incr: async k => { const v = (store.get(k) || 0) + 1; store.set(k, v); return v; },
expire: async () => 1
};
}
function loadLimiter(redis) {
const target = require.resolve('../src/middleware/rateLimit');
delete require.cache[target];
const original = Module._load;
Module._load = function(request, parent) {
if (/utils\/redis$/.test(request)) return { getRedis: async () => redis };
return original.apply(this, arguments);
};
try { return require(target); } finally { Module._load = original; }
}
function run(mw, userId) {
return new Promise(resolve => {
const res = { headers: {}, set(k, v) { this.headers[k] = v; }, status(c) { this.code = c; return this; }, json(b) { this.body = b; resolve({ res, next: false }); } };
mw({ user: { id: userId } }, res, () => resolve({ res, next: true }));
});
}
test('the limit counts per user per window and answers 429 with a retry hint past it', async () => {
const { rateLimit } = loadLimiter(fakeRedis());
const mw = rateLimit('t', { limit: 2, windowSeconds: 60 });
assert.equal((await run(mw, 1)).next, true);
assert.equal((await run(mw, 1)).next, true);
const third = await run(mw, 1);
assert.equal(third.next, false);
assert.equal(third.res.code, 429);
assert.equal(third.res.body.code, 'RATE_LIMITED');
assert.ok(Number(third.res.headers['Retry-After']) >= 1);
assert.equal((await run(mw, 2)).next, true, 'another user has their own count');
});
test('without Redis nothing is refused', async () => {
const { rateLimit } = loadLimiter(null);
const mw = rateLimit('t', { limit: 1, windowSeconds: 60 });
assert.equal((await run(mw, 1)).next, true);
assert.equal((await run(mw, 1)).next, true);
});
test('the paid assistant routes sit behind the limiter, and the library search is cached briefly', () => {
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8');
assert.match(route, /router\.post\(\['\/clinical-assistant\/chat', '\/clinical-assistant\/chat\/stream', '\/clinical-assistant\/image', '\/clinical-assistant\/image\/jobs'\], askLimit\)/);
assert.match(route, /rateLimit\('clinical-ask', \{\s*limit: positiveIntEnv\('CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE', 30\), windowSeconds: 60/);
assert.match(route, /await cachedSemanticSearch\(searchQuery, \{/);
assert.match(route, /RETRIEVAL_CACHE_TTL_S = positiveIntEnv\('CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S', 60\)/);
assert.match(route, /if \(!RETRIEVAL_CACHE_TTL_S \|\| !cache\) return semanticSearch\(query, opts\)/, '0 turns the cache off, and so does a missing cache');
assert.match(route, /cache\.setJson\(key, response, RETRIEVAL_CACHE_TTL_S\)\.catch/, 'a cache write failure never fails the answer');
const client = fs.readFileSync(path.join(__dirname, '..', 'src/utils/clinicalMcpClient.js'), 'utf8');
assert.match(client, /e\.message\s*=|new Error\('The library is busy right now/, 'the busy answer reads as a sentence');
assert.doesNotMatch(client.slice(client.indexOf('function busyError'), client.indexOf('function acquireSlot')), /MCP/, 'so assistantErrorMessage passes it through unchanged');
});

View file

@ -81,7 +81,7 @@ test('translate route is owner-bound, validated and cached; admin default provid
const db = { async getSetting(key) { return null; }, async get() { return null; }, async run() { return { lastInsertRowid: 1 }; }, async query() { return { rows: [] }; } };
const mocks = {
express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translatedText: 'Traducción sintética.' } }; } }, crypto: require('node:crypto'),
'../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) },
'../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) },
'../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
'../utils/crypto': { encryptString: v => 'encrypted:' + v, decryptString: v => v.replace(/^encrypted:/, '') },
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },

View file

@ -37,6 +37,7 @@ function server(options = {}) {
const mocks = {
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
'../middleware/auth': { authMiddleware() {} },
'../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } },
'./auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },

View file

@ -76,6 +76,7 @@ function route(file, ai, jobs) {
express, axios:{}, crypto:require('crypto'), multer:require('multer'), path:require('path'),
'../utils/ai':ai, '../db/database':{getSetting:async key=>key.includes('model')?'test':null,all:async()=>[]},
'../middleware/auth':{authMiddleware(){},moderatorMiddleware(){}},
'../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } },
'../utils/crypto':{},'../utils/urlSafety':{},'../utils/policy':{requireFeature:()=>()=>{}},
// Retrieval is opt-in and these cases do not ask for it; the stub proves
// the route never reaches the corpus unless useCorpus was set.

View file

@ -6,10 +6,17 @@ const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', 'src/utils/clinicalMcpClient.js'), 'utf8');
// The slot and queue logic on its own, with the bounds given explicitly.
function slots(concurrency, queueMax, waitMs) {
const slice = src.slice(src.indexOf('function busyError'), src.indexOf('async function callMcpTool'));
return new Function('MCP_CONCURRENCY', 'MCP_QUEUE_MAX', 'MCP_QUEUE_WAIT_MS',
'var _inFlight = 0, _waiting = [];' + slice +
'; return { acquireSlot, releaseSlot, count: () => _inFlight, waiting: () => _waiting.length };')(concurrency, queueMax, waitMs);
}
test('a bounded number of calls run at once; the rest wait their turn', async () => {
// The slot logic on its own, with the environment's default bound.
const slice = src.slice(src.indexOf('function acquireSlot'), src.indexOf('async function callMcpTool'));
const fn = new Function('MCP_CONCURRENCY', 'var _inFlight = 0, _waiting = [];' + slice + '; return { acquireSlot, releaseSlot, count: () => _inFlight, waiting: () => _waiting.length };')(3);
const fn = slots(3, 12, 8000);
await fn.acquireSlot(); await fn.acquireSlot(); await fn.acquireSlot();
assert.equal(fn.count(), 3);
let fourthStarted = false;
@ -25,6 +32,36 @@ test('a bounded number of calls run at once; the rest wait their turn', async ()
assert.equal(fn.count(), 0);
});
test('past the queue bound a caller is told the library is busy, not held', async () => {
const fn = slots(1, 2, 8000);
await fn.acquireSlot();
const second = fn.acquireSlot(); const third = fn.acquireSlot();
assert.equal(fn.waiting(), 2);
await assert.rejects(fn.acquireSlot(), e => e.statusCode === 503 && e.code === 'RETRIEVAL_BUSY' && e.retryAfterSeconds === 5);
fn.releaseSlot(); await second; fn.releaseSlot(); await third; fn.releaseSlot();
assert.equal(fn.count(), 0);
});
test('a caller that waits too long is released from the line, and the line forgets it', async () => {
const fn = slots(1, 4, 20);
// The wait timer is unref'd (a live search keeps the process up in real
// use); hold the loop open here so it can fire.
const hold = setTimeout(() => {}, 200);
await fn.acquireSlot();
const late = fn.acquireSlot();
await assert.rejects(late, e => e.code === 'RETRIEVAL_BUSY');
assert.equal(fn.waiting(), 0, 'the timed-out waiter is gone');
fn.releaseSlot();
assert.equal(fn.count(), 0, 'releasing does not hand the slot to a caller that already gave up');
clearTimeout(hold);
});
test('the queue bounds and the busy answer are wired where the route can see them', () => {
assert.match(src, /MCP_QUEUE_MAX = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_QUEUE_MAX, MCP_CONCURRENCY \* 4\)/);
assert.match(src, /MCP_QUEUE_WAIT_MS = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_QUEUE_WAIT_MS, 8000\)/);
assert.match(src, /queueDepth: queueDepth/);
});
test('the serial promise chain is gone, the session is kept warm, and the split is logged', () => {
assert.doesNotMatch(src, /_mcpCallQueue = queued\.catch/, 'no more one-at-a-time chain');
assert.match(src, /var MCP_CONCURRENCY = positiveInt\(process\.env\.CLINICAL_ASSISTANT_MCP_CONCURRENCY, 3\)/);

View file

@ -57,6 +57,7 @@ function router(t, overrides = {}) {
}
},
'../middleware/auth': { authMiddleware: (req, res, next) => next() },
'../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } },
'../utils/ai': {
callAI: async (messages, options) => {
aiCalls.push({ messages, options });

View file

@ -33,6 +33,7 @@ function server(t, overrides = {}) {
query: async () => ({ rows: [] })
},
'../middleware/auth': { authMiddleware: (req, res, next) => next() },
'../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } },
'../utils/ai': { callAI: async (messages, options) => { aiCalls.push({ messages, options }); return { content: 'Take home [1] text.', model: 'synthetic-chat' }; }, callAIStream: async () => { throw new Error('unexpected'); }, activeProvider: 'synthetic', discoverModels: async () => [], vertexClient: null, litellmClient: null, applyImageAttachments: x => x },
'../utils/generatedImages': { workflows: ['clinical_assistant'], snapshot: async () => ({}), enqueue: async () => ({}), tick: async () => {}, ready: async () => {} },
'../utils/visionTool': require('../src/utils/visionTool'),
@ -132,6 +133,7 @@ test('a text-only answer no longer conjures an image; the model must call the to
axios: {},
'../db/database': { get: async () => null, getSetting: async () => '', all: async () => [], run: async () => ({ changes: 1, lastInsertRowid: 1 }), query: async () => ({ rows: [] }) },
'../middleware/auth': { authMiddleware: (req, res, next) => next() },
'../middleware/rateLimit': { rateLimit() { return function(req, res, next) { next(); }; } },
'../utils/ai': { callAI: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }), callAIStream: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }) },
'../utils/generatedImages': { service: () => ({ enqueue: async (owner, workflow, input, key, replay, context, model) => { jobs.push({ owner, workflow, input, model }); return { jobId: 'job-x', status: 'pending', imageUrl: null }; } }), imageContext: (r, h) => ({ request: r, history: h }), requestKey: b => 'k' + String(b).length },
'../utils/imageTool': { tools: [], dispatch: async ai => ai },