@mcp-abap-adt/llm-agent-server 16.2.0 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,10 +4,11 @@
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import http from 'node:http';
6
6
  import { AdapterValidationError, normalizeAndValidateExternalTools, QueryEmbedding, toToolCallDelta, } from '@mcp-abap-adt/llm-agent';
7
- import { ClaudeSkillManager, CodexSkillManager, ConfigWatcher, DefaultSubAgentContextBuilder, FileSystemPluginLoader, FileSystemSkillManager, getDefaultPluginDirs, HealthChecker, makeLlm, SessionLogger, SmartAgentBuilder, SmartAgentSubAgent, } from '@mcp-abap-adt/llm-agent-libs';
7
+ import { ClaudeSkillManager, CodexSkillManager, ConfigWatcher, DefaultSubAgentContextBuilder, FileSystemPluginLoader, FileSystemSkillManager, getDefaultPluginDirs, HealthChecker, makeLlm, SessionGraphFactory, SessionLogger, SessionRegistry, SmartAgentBuilder, SmartAgentSubAgent, SubAgentStateOracle, } from '@mcp-abap-adt/llm-agent-libs';
8
8
  import { makeRag } from '@mcp-abap-adt/llm-agent-rag';
9
9
  import { PACKAGE_VERSION } from '../generated/version.js';
10
10
  import { resolveAgentEmbedder, resolveToolsStoreEmbedder, } from './resolve-agent-embedder.js';
11
+ import { resolveSessionIdentity } from './session-identity-resolver.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
13
14
  // ---------------------------------------------------------------------------
@@ -65,11 +66,198 @@ const CORS_HEADERS = {
65
66
  // ---------------------------------------------------------------------------
66
67
  // SmartServer
67
68
  // ---------------------------------------------------------------------------
68
- import { resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorPlanning, resolveToolSelectionStrategy, } from './config.js';
69
+ import { buildDagCoordinatorDeps } from './build-dag-coordinator-deps.js';
70
+ import { assertCoordinatorConfigShape, normalizeLlmConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorDispatchKind, resolveCoordinatorPlanning, resolveLlmConfig, resolveLlmConfigStrict, resolveToolSelectionStrategy, } from './config.js';
69
71
  export { generateConfigTemplate, loadYamlConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorPlanning, resolveEnvVars, resolveSmartServerConfig, resolveToolSelectionStrategy, YAML_TEMPLATE, } from './config.js';
72
+ /**
73
+ * Drain every cached worker's `close` (if any), then clear the cache map.
74
+ * Used by config-reload (PUT /v1/config + hot-reload — Fix #14/18/21) and by
75
+ * server `close()` to release per-worker MCP connections that were attached
76
+ * to the discarded `SmartAgentHandle`s.
77
+ *
78
+ * IMPORTANT — in-flight caveat: this aborts any request that is mid-call on
79
+ * a worker's MCP client. That is acceptable for an admin action (config
80
+ * reload, server shutdown) where the alternative is leaking connections.
81
+ * Server `close()` calls this AFTER `lifecycle.disposeAll()` so per-session
82
+ * graphs that reference worker clients are torn down first.
83
+ *
84
+ * Uses `Promise.allSettled` so one failing close cannot block the others.
85
+ */
86
+ export async function drainWorkerCache(cache) {
87
+ const closers = [];
88
+ for (const entry of cache.values()) {
89
+ if (entry.close) {
90
+ try {
91
+ closers.push(entry.close());
92
+ }
93
+ catch {
94
+ // sync throw (defensive — close is async by contract)
95
+ }
96
+ }
97
+ }
98
+ cache.clear();
99
+ if (closers.length > 0) {
100
+ await Promise.allSettled(closers);
101
+ }
102
+ }
103
+ /**
104
+ * Build-once-per-worker resolver. The first time a worker name is seen, it
105
+ * constructs the worker's main/classifier/(optional helper) LLM + embedder and
106
+ * caches the set; every later call (e.g. each per-session worker re-wire)
107
+ * returns the SAME set by reference — never reconstructing LLM clients
108
+ * (locked invariant: LLM/embedder clients are global, built once).
109
+ *
110
+ * Accepts optional `makeToolsRag`/`makeHistoryRag`/`makeMcpClients` factories;
111
+ * when provided, the resolver builds them ONCE on the first miss and caches
112
+ * them on the returned set. Subsequent calls return the cached resources by
113
+ * reference — never re-vectorizing or re-connecting MCP.
114
+ */
115
+ export async function resolveWorkerLlmSet(input) {
116
+ const hit = input.cache.get(input.name);
117
+ if (hit)
118
+ return hit;
119
+ const mainLlm = await input.makeMain();
120
+ const classifierLlm = await input.makeClassifier();
121
+ const helperLlm = input.makeHelper ? await input.makeHelper() : undefined;
122
+ const embedder = input.makeEmbedder ? await input.makeEmbedder() : undefined;
123
+ const toolsRag = input.makeToolsRag ? await input.makeToolsRag() : undefined;
124
+ const historyRag = input.makeHistoryRag
125
+ ? await input.makeHistoryRag()
126
+ : undefined;
127
+ const mcpClients = input.makeMcpClients
128
+ ? await input.makeMcpClients()
129
+ : undefined;
130
+ const set = {
131
+ mainLlm,
132
+ classifierLlm,
133
+ helperLlm,
134
+ embedder,
135
+ toolsRag,
136
+ historyRag,
137
+ mcpClients,
138
+ };
139
+ input.cache.set(input.name, set);
140
+ return set;
141
+ }
142
+ /**
143
+ * Backfill the per-worker cache entry from the BUILT handle (review HIGH #7).
144
+ *
145
+ * The primary `buildSubAgent` populates `cached.mcpClients`/`toolsRag`/
146
+ * `historyRag` only when the worker config provided DI factories. Workers
147
+ * configured with `subCfg.mcp: ...` (regular config that triggers the
148
+ * builder's own auto-connect) or with `subCfg.rag: ...` whose RAG is owned
149
+ * by the builder leave those slots empty — so per-session re-wires would
150
+ * fall back to the PARENT's MCP/RAG, losing the worker's own connection.
151
+ *
152
+ * After the builder finishes, this helper captures what the handle actually
153
+ * holds and stores it BY REFERENCE on the cache entry. Subsequent per-session
154
+ * re-wires read the same slots and find the worker's own resources.
155
+ *
156
+ * Pure helper, mutates `entry` in place. No-op when the corresponding slot
157
+ * is already populated (DI path wins) or when the handle has no resource for
158
+ * that slot (worker simply didn't declare one).
159
+ */
160
+ export async function backfillWorkerCacheFromHandle(entry, handle) {
161
+ if ((!entry.mcpClients || entry.mcpClients.length === 0) &&
162
+ handle.mcpClients &&
163
+ handle.mcpClients.length > 0) {
164
+ entry.mcpClients = handle.mcpClients;
165
+ }
166
+ if (!entry.toolsRag) {
167
+ const t = handle.ragRegistry.get('tools');
168
+ if (t)
169
+ entry.toolsRag = t;
170
+ }
171
+ if (!entry.historyRag) {
172
+ const h = handle.ragRegistry.get('history');
173
+ if (h)
174
+ entry.historyRag = h;
175
+ }
176
+ // Capture the per-worker shutdown function (Fix #21). If the entry already
177
+ // had a close from a previous build (e.g. the same worker name was rebuilt
178
+ // WITHOUT going through `drainWorkerCache` first — defence in depth), await
179
+ // the prior close before overwriting so its MCP connections do not leak.
180
+ if (handle.close) {
181
+ if (entry.close) {
182
+ try {
183
+ await entry.close();
184
+ }
185
+ catch {
186
+ // Best-effort; never block the new build on a stale close failure.
187
+ }
188
+ }
189
+ entry.close = handle.close;
190
+ }
191
+ }
192
+ /**
193
+ * Share the parent RAG registry with subagents (per-session worker re-wire).
194
+ * Session/user/global collections written at the top level become visible to
195
+ * workers; the per-call scope filter (`rag-query.ts`) isolates by
196
+ * `ctx.sessionId` / `ctx.options.userId`. A worker's own declared store is
197
+ * registered INTO this same registry under its namespace. When the parent
198
+ * registry is undefined (no top-level registry yet — e.g. unit test seam),
199
+ * return undefined so the builder allocates its own SimpleRagRegistry.
200
+ */
201
+ export function resolveSubAgentRagRegistry(input) {
202
+ return input.parentRagRegistry;
203
+ }
204
+ /**
205
+ * Composes the cookie identity resolver + SessionGraphFactory + SessionRegistry
206
+ * into one lifecycle object the server's `_handle` consumes. The default MCP
207
+ * factory returns the shared GLOBAL clients by reference (one upstream
208
+ * connection); a creds-aware build swaps it out (out of scope here).
209
+ */
210
+ export function buildSessionLifecycle(opts) {
211
+ const factory = new SessionGraphFactory({
212
+ mcpClientFactory: (_identity) => opts.mcpClients,
213
+ toolsRag: opts.toolsRag,
214
+ ragRegistry: opts.ragRegistry,
215
+ buildAgent: opts.buildAgent,
216
+ logger: opts.logger,
217
+ });
218
+ const registry = new SessionRegistry({
219
+ idleTtlMs: opts.idleTtlMs,
220
+ maxSessions: opts.maxSessions,
221
+ factory,
222
+ });
223
+ return {
224
+ resolve: (cookieHeader, isHttps) => resolveSessionIdentity({
225
+ cookieHeader,
226
+ cookieName: opts.cookieName,
227
+ maxAgeSeconds: Math.max(1, Math.floor(opts.idleTtlMs / 1000)),
228
+ isHttps,
229
+ }),
230
+ acquire: (sessionId) => registry.acquire(sessionId),
231
+ release: (sessionId, graph) => registry.release(sessionId, graph),
232
+ evictIdle: () => registry.evictIdle(),
233
+ disposeAll: () => registry.disposeAll(),
234
+ invalidateAll: () => registry.invalidateAll(),
235
+ registry,
236
+ };
237
+ }
70
238
  export class SmartServer {
71
239
  cfg;
72
240
  noop = () => { };
241
+ /**
242
+ * GLOBAL per-worker LLM/embedder cache. Populated lazily by `buildSubAgent`
243
+ * the first time each worker name is seen; subsequent per-session re-wires
244
+ * pull from this cache by reference (never reconstructing LLM clients).
245
+ */
246
+ _workerLlmCache = new Map();
247
+ /** Lifecycle handle wired in `start()`; consumed by `_handle`. */
248
+ _lifecycle;
249
+ /** Hoisted globals used by `buildSessionAgent` to re-wire fresh per-session workers. */
250
+ _mainLlm;
251
+ _classifierLlm;
252
+ _helperLlm;
253
+ _fileLogger;
254
+ _mergedEmbedderFactories;
255
+ /**
256
+ * Captured DAG coordinator template — `deps` are stateless or LLM-bound and
257
+ * are reused across sessions; only `workers` + `stateOracle` are re-wired per
258
+ * session (review HIGH #1).
259
+ */
260
+ _dagCoordinatorTemplate;
73
261
  constructor(config) {
74
262
  this.cfg = config;
75
263
  }
@@ -78,38 +266,63 @@ export class SmartServer {
78
266
  const fileLogger = {
79
267
  log: (e) => log(e),
80
268
  };
269
+ this._fileLogger = fileLogger;
81
270
  const pipeline = this.cfg.pipeline;
82
271
  // ---- Composition root: resolve config → interfaces --------------------
83
- // LLM resolution
84
- const mainTemp = Number(pipeline?.llm?.main?.temperature ?? this.cfg.llm.temperature ?? 0.7);
272
+ // LLM resolution — normalize flat/map cfg.llm and derive pipeline fallback.
273
+ const llmMap = normalizeLlmConfig(this.cfg.llm);
274
+ // Adapt pipeline.llm.main (uses `baseURL`) → SmartServerLlmConfig (uses
275
+ // `url`) so resolveLlmConfig's pipelineFallback parameter speaks one
276
+ // shape. Returns undefined when no pipeline.llm.main is configured.
277
+ const pipelineFallback = (() => {
278
+ const pm = pipeline?.llm?.main;
279
+ if (!pm)
280
+ return undefined;
281
+ return {
282
+ provider: pm.provider,
283
+ apiKey: pm.apiKey ?? '',
284
+ url: pm.baseURL,
285
+ model: pm.model,
286
+ temperature: pm.temperature,
287
+ };
288
+ })();
289
+ const topMain = resolveLlmConfig(llmMap, 'main', pipelineFallback);
290
+ const mainTemp = Number(pipeline?.llm?.main?.temperature ?? topMain?.temperature ?? 0.7);
85
291
  const mainLlm = pipeline?.llm?.main
86
292
  ? await makeLlm(pipeline.llm.main, mainTemp)
87
- : await makeLlm({
88
- // ?? 'deepseek' is a TS type-narrowing net only; the config validator
89
- // rejects a missing flat-schema provider before this runs.
90
- provider: this.cfg.llm.provider ?? 'deepseek',
91
- apiKey: this.cfg.llm.apiKey,
92
- baseURL: this.cfg.llm.url,
93
- model: this.cfg.llm.model,
94
- }, mainTemp);
293
+ : topMain
294
+ ? await makeLlm({
295
+ provider: topMain.provider ?? 'deepseek',
296
+ apiKey: topMain.apiKey,
297
+ baseURL: topMain.url,
298
+ model: topMain.model,
299
+ }, mainTemp)
300
+ : (() => {
301
+ throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
302
+ })();
95
303
  const classifierTemp = Number(pipeline?.llm?.classifier?.temperature ??
96
- this.cfg.llm.classifierTemperature ??
304
+ topMain?.classifierTemperature ??
97
305
  0.1);
98
306
  const classifierLlm = pipeline?.llm?.classifier
99
307
  ? await makeLlm(pipeline.llm.classifier, classifierTemp)
100
308
  : pipeline?.llm?.main
101
309
  ? await makeLlm(pipeline.llm.main, classifierTemp)
102
- : await makeLlm({
103
- // ?? 'deepseek' is a TS type-narrowing net only; the config validator
104
- // rejects a missing flat-schema provider before this runs.
105
- provider: this.cfg.llm.provider ?? 'deepseek',
106
- apiKey: this.cfg.llm.apiKey,
107
- baseURL: this.cfg.llm.url,
108
- model: this.cfg.llm.model,
109
- }, classifierTemp);
310
+ : topMain
311
+ ? await makeLlm({
312
+ provider: topMain.provider ?? 'deepseek',
313
+ apiKey: topMain.apiKey,
314
+ baseURL: topMain.url,
315
+ model: topMain.model,
316
+ }, classifierTemp)
317
+ : (() => {
318
+ throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
319
+ })();
110
320
  const helperLlm = pipeline?.llm?.helper
111
321
  ? await makeLlm(pipeline.llm.helper, Number(pipeline.llm.helper.temperature ?? 0.1))
112
322
  : undefined;
323
+ this._mainLlm = mainLlm;
324
+ this._classifierLlm = classifierLlm;
325
+ this._helperLlm = helperLlm;
113
326
  // ---- Plugin loader -------------------------------------------------------
114
327
  const pluginLoader = this.cfg.pluginLoader ??
115
328
  (() => {
@@ -143,6 +356,7 @@ export class SmartServer {
143
356
  ...plugins.embedderFactories,
144
357
  ...this.cfg.embedderFactories, // config takes precedence over plugins
145
358
  };
359
+ this._mergedEmbedderFactories = mergedEmbedderFactories;
146
360
  // Resolve the embedder ONCE so the same instance feeds both makeRag and the
147
361
  // subagent context-builder's toolSource (#137). See resolve-agent-embedder.
148
362
  let resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this.cfg.embedder, mergedEmbedderFactories);
@@ -267,8 +481,9 @@ export class SmartServer {
267
481
  // Build SubAgentRegistry from `subagents:` YAML block (if present).
268
482
  // Each sub-agent is a minimal SmartAgent reusing the parent's plugin
269
483
  // outputs (embedder factories, plugins) but with its own LLM/RAG/MCP/etc.
484
+ // Hoisted so the DAG branch below can reuse the same instances.
485
+ const registry = new Map();
270
486
  if (this.cfg.subAgentConfigs && this.cfg.subAgentConfigs.length > 0) {
271
- const registry = new Map();
272
487
  for (const sub of this.cfg.subAgentConfigs) {
273
488
  const subAgent = await this.buildSubAgent(sub.name, sub.config, fileLogger, mergedEmbedderFactories);
274
489
  registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
@@ -285,51 +500,128 @@ export class SmartServer {
285
500
  // ---- Coordinator (autonomous plan-execute loop) ------------------------
286
501
  const coordCfg = this.cfg.coordinatorYaml;
287
502
  if (coordCfg) {
288
- // Pick the planner LLM. Default 'main' reuse mainLlm; 'planner' or
289
- // 'helper' → helperLlm if present, else fall back to mainLlm.
290
- const plannerLlm = coordCfg.plannerLlm === 'main' ? mainLlm : (helperLlm ?? mainLlm);
291
- // For 'skill-steps' planning the default dispatch is 'hybrid' rather
292
- // than 'subagent': skill-step `agent:` is optional, so steps without
293
- // an explicit agent need a self-LLM fallback. Users can still pin
294
- // dispatch: subagent explicitly when every step declares `agent:`.
295
- const planningKind = coordCfg.planning ?? 'one-shot';
296
- const dispatchKind = coordCfg.dispatch ??
297
- (planningKind === 'skill-steps' ? 'hybrid' : 'subagent');
298
- // Build the same kind of context builder SmartAgentBuilder uses, so
299
- // YAML-configured deployments get the same default behavior. Uses the
300
- // embedder resolved above — DI-injected (this.cfg.embedder) when present,
301
- // otherwise constructed from `rag.embedder` (#137). Stays undefined for
302
- // bare in-memory BM25 stores (no embedder), in which case toolSource is
303
- // skipped and constrained subagents fall back to empty context.
304
- const mainEmbedder = resolvedEmbedder;
305
- const toolSource = mainEmbedder && toolsRag
306
- ? async (text, k, signal) => {
307
- const embedding = new QueryEmbedding(text, mainEmbedder, {
308
- signal,
309
- });
310
- const r = await toolsRag.query(embedding, k, { signal });
311
- return r.ok ? r.value : [];
503
+ // Fail loud if the config mixes DAG and linear fields.
504
+ assertCoordinatorConfigShape(coordCfg);
505
+ if (coordCfg.planner !== undefined) {
506
+ // DAG mode: `planner` field present use DagCoordinatorHandler.
507
+ // planner shape/type already validated by assertCoordinatorConfigShape.
508
+ // Validate interpreter.type if present only 'dag' is supported.
509
+ const interpKind = coordCfg.interpreter?.type;
510
+ if (interpKind !== undefined && interpKind !== 'dag') {
511
+ throw new Error(`coordinator.interpreter: unknown type '${interpKind}' (only 'dag' is supported)`);
312
512
  }
313
- : undefined;
314
- const contextBuilder = new DefaultSubAgentContextBuilder({ toolSource });
315
- builder = builder.withCoordinator({
316
- planning: resolveCoordinatorPlanning(planningKind, plannerLlm),
317
- dispatch: resolveCoordinatorDispatch(dispatchKind, plannerLlm, contextBuilder),
318
- // Default to 'explicit' — the presence of a `coordinator:` block in
319
- // YAML is itself the opt-in signal. Users that want the auto-fallback
320
- // semantics (no subagents and no skill steps → tool-loop) must set
321
- // `activation: auto` explicitly.
322
- activation: resolveCoordinatorActivation(coordCfg.activation ?? 'explicit'),
323
- plannerLlm,
324
- maxSteps: coordCfg.maxSteps,
325
- maxRetriesPerStep: coordCfg.maxRetriesPerStep,
326
- failPolicy: coordCfg.failPolicy,
327
- maxLayer: coordCfg.maxLayer,
328
- });
329
- log({ event: 'coordinator_configured', config: coordCfg });
513
+ const built = await buildDagCoordinatorDeps({
514
+ coordCfg: coordCfg,
515
+ llmMap,
516
+ pipelineFallback,
517
+ mainLlm,
518
+ helperLlm,
519
+ mainTemp,
520
+ registry,
521
+ makeLlm: async (lc) => makeLlm({
522
+ provider: lc.provider ?? 'deepseek',
523
+ apiKey: lc.apiKey,
524
+ baseURL: lc.url,
525
+ model: lc.model,
526
+ }, Number(lc.temperature ?? mainTemp)),
527
+ warn: (m) => log({ event: 'config_warning', message: m }),
528
+ });
529
+ // built is defined when coordCfg.planner !== undefined.
530
+ if (!built) {
531
+ throw new Error('internal: buildDagCoordinatorDeps returned undefined despite planner present');
532
+ }
533
+ const { oracleName, ...deps } = built;
534
+ builder = builder.withDagCoordinator(deps);
535
+ // Capture template for per-session re-wire (workers + stateOracle are
536
+ // re-wired per session; everything else is stateless or LLM-bound).
537
+ this._dagCoordinatorTemplate = {
538
+ deps: {
539
+ planner: deps.planner,
540
+ interpreter: deps.interpreter,
541
+ activation: deps.activation,
542
+ reviewer: deps.reviewer,
543
+ errorStrategy: deps.errorStrategy,
544
+ finalizer: deps.finalizer,
545
+ maxRoundTrips: deps.maxRoundTrips,
546
+ },
547
+ oracleName,
548
+ };
549
+ log({ event: 'dag_coordinator_configured', config: coordCfg });
550
+ }
551
+ else {
552
+ // Linear mode: route plannerLlm through the normalized map chain.
553
+ // Priority: map[name] → 'helper'/'planner' alias (helperLlm) →
554
+ // pipelineFallback → mainLlm.
555
+ const linearPlannerName = coordCfg.plannerLlm;
556
+ // Role-resolution order:
557
+ // 1. explicit map[name] → build from that entry
558
+ // 2. name === 'helper' | 'planner' → reuse prebuilt helperLlm
559
+ // 3. unknown name → resolveLlmConfig fallback chain (map.main → pipelineFallback)
560
+ // 4. no name → mainLlm
561
+ const linearPlannerCfgStrict = resolveLlmConfigStrict(llmMap, linearPlannerName);
562
+ const plannerLlm = linearPlannerCfgStrict
563
+ ? await makeLlm({
564
+ provider: linearPlannerCfgStrict.provider ?? 'deepseek',
565
+ apiKey: linearPlannerCfgStrict.apiKey,
566
+ baseURL: linearPlannerCfgStrict.url,
567
+ model: linearPlannerCfgStrict.model,
568
+ }, Number(linearPlannerCfgStrict.temperature ?? mainTemp))
569
+ : linearPlannerName === 'helper' || linearPlannerName === 'planner'
570
+ ? (helperLlm ?? mainLlm)
571
+ : linearPlannerName
572
+ ? // Unknown name, not an alias — try pipelineFallback last.
573
+ await (async () => {
574
+ const fb = resolveLlmConfig(llmMap, linearPlannerName, pipelineFallback);
575
+ return fb
576
+ ? makeLlm({
577
+ provider: fb.provider ?? 'deepseek',
578
+ apiKey: fb.apiKey,
579
+ baseURL: fb.url,
580
+ model: fb.model,
581
+ }, Number(fb.temperature ?? mainTemp))
582
+ : mainLlm;
583
+ })()
584
+ : mainLlm;
585
+ const planningKind = coordCfg.planning ?? 'one-shot';
586
+ const dispatchKind = resolveCoordinatorDispatchKind(coordCfg.dispatch);
587
+ // Build the same kind of context builder SmartAgentBuilder uses, so
588
+ // YAML-configured deployments get the same default behavior. Uses the
589
+ // embedder resolved above — DI-injected (this.cfg.embedder) when present,
590
+ // otherwise constructed from `rag.embedder` (#137). Stays undefined for
591
+ // bare in-memory BM25 stores (no embedder), in which case toolSource is
592
+ // skipped and constrained subagents fall back to empty context.
593
+ const mainEmbedder = resolvedEmbedder;
594
+ const toolSource = mainEmbedder && toolsRag
595
+ ? async (text, k, signal) => {
596
+ const embedding = new QueryEmbedding(text, mainEmbedder, {
597
+ signal,
598
+ });
599
+ const r = await toolsRag.query(embedding, k, { signal });
600
+ return r.ok ? r.value : [];
601
+ }
602
+ : undefined;
603
+ const contextBuilder = new DefaultSubAgentContextBuilder({
604
+ toolSource,
605
+ });
606
+ builder = builder.withCoordinator({
607
+ planning: resolveCoordinatorPlanning(planningKind, plannerLlm),
608
+ dispatch: resolveCoordinatorDispatch(dispatchKind, plannerLlm, contextBuilder),
609
+ // Default to 'explicit' — the presence of a `coordinator:` block in
610
+ // YAML is itself the opt-in signal. Users that want the auto-fallback
611
+ // semantics (no subagents and no skill steps → tool-loop) must set
612
+ // `activation: auto` explicitly.
613
+ activation: resolveCoordinatorActivation(coordCfg.activation ?? 'explicit'),
614
+ plannerLlm,
615
+ maxSteps: coordCfg.maxSteps,
616
+ maxRetriesPerStep: coordCfg.maxRetriesPerStep,
617
+ failPolicy: coordCfg.failPolicy,
618
+ });
619
+ log({ event: 'coordinator_configured', config: coordCfg });
620
+ }
330
621
  }
331
622
  const agentHandle = await builder.build();
332
623
  const { agent: smartAgent, chat, streamChat, close: closeAgent, circuitBreakers, ragStores, modelProvider, } = agentHandle;
624
+ const { ragRegistry: globalRagRegistry, mcpClients: globalMcpClients } = agentHandle;
333
625
  // ---- API adapter map (built-in → config DI; DI wins) --------------------
334
626
  const { OpenAiApiAdapter, AnthropicApiAdapter } = await import('@mcp-abap-adt/llm-agent');
335
627
  const adapterMap = new Map();
@@ -345,6 +637,35 @@ export class SmartServer {
345
637
  }
346
638
  }
347
639
  const closeFns = [closeAgent];
640
+ // ---- Per-session lifecycle (cookie identity + graph factory + registry) ----
641
+ const sessionCfg = this.cfg.session ?? {};
642
+ const idleTtlMs = sessionCfg.idleTtlMs ?? 7_200_000;
643
+ const lifecycle = buildSessionLifecycle({
644
+ idleTtlMs,
645
+ maxSessions: sessionCfg.maxSessions ?? 1000,
646
+ cookieName: sessionCfg.cookieName ?? 'sid',
647
+ mcpClients: globalMcpClients,
648
+ toolsRag,
649
+ ragRegistry: globalRagRegistry,
650
+ buildAgent: (parts) => this.buildSessionAgent(parts),
651
+ logger: fileLogger,
652
+ });
653
+ this._lifecycle = lifecycle;
654
+ const sweepMs = Math.min(idleTtlMs, 60_000);
655
+ const sweep = setInterval(() => {
656
+ void lifecycle.evictIdle();
657
+ }, sweepMs);
658
+ sweep.unref?.();
659
+ closeFns.push(async () => {
660
+ clearInterval(sweep);
661
+ await lifecycle.disposeAll();
662
+ // Fix #21: per-session graphs may reference worker MCP clients, so
663
+ // dispose them FIRST (above), THEN drain per-worker handle.close so the
664
+ // worker-owned MCP clients themselves disconnect. Ordering matters —
665
+ // closing MCP clients while a session graph is mid-use would cut its
666
+ // request short.
667
+ await drainWorkerCache(this._workerLlmCache);
668
+ });
348
669
  const startTime = Date.now();
349
670
  const healthChecker = new HealthChecker({
350
671
  agent: smartAgent,
@@ -383,7 +704,59 @@ export class SmartServer {
383
704
  agentUpdate.classificationEnabled = update.classificationEnabled;
384
705
  if (Object.keys(agentUpdate).length > 0) {
385
706
  smartAgent.applyConfigUpdate(agentUpdate);
707
+ // Mirror onto `this.cfg.agent` so freshly-built session graphs
708
+ // (which read `this.cfg.agent` in `buildSessionAgent`) observe the
709
+ // update. Deep-merge to preserve untouched startup fields.
710
+ // Note: `agentUpdate` includes flat fields ONLY whitelisted by
711
+ // `AGENT_CONFIG_FIELDS` plus the two prompt fields, which we route
712
+ // into `this.cfg.prompts` separately below.
713
+ const agentPatch = {};
714
+ for (const k of Object.keys(agentUpdate)) {
715
+ if (k !== 'ragTranslatePrompt' && k !== 'historySummaryPrompt') {
716
+ agentPatch[k] = agentUpdate[k];
717
+ }
718
+ }
719
+ if (Object.keys(agentPatch).length > 0) {
720
+ const mergedAgent = {
721
+ ...(this.cfg.agent ??
722
+ {}),
723
+ ...agentPatch,
724
+ };
725
+ this.cfg.agent =
726
+ mergedAgent;
727
+ }
728
+ if (update.prompts?.ragTranslate !== undefined ||
729
+ update.prompts?.historySummary !== undefined) {
730
+ const mergedPrompts = {
731
+ ...(this.cfg.prompts ??
732
+ {}),
733
+ };
734
+ if (update.prompts?.ragTranslate !== undefined) {
735
+ mergedPrompts.ragTranslate = update.prompts.ragTranslate;
736
+ }
737
+ if (update.prompts?.historySummary !== undefined) {
738
+ mergedPrompts.historySummary = update.prompts.historySummary;
739
+ }
740
+ this.cfg.prompts =
741
+ mergedPrompts;
742
+ }
386
743
  }
744
+ // Per-session graphs (built by SessionGraphFactory) captured the OLD
745
+ // config and the OLD cached worker LLM set. Without invalidation,
746
+ // existing sessions keep the stale SmartAgent and a fresh acquire on a
747
+ // cookie-known sessionId still returns it. Clear the worker cache so
748
+ // the next build reads from the just-applied config, then drop every
749
+ // session graph. Failures are non-fatal — log and continue.
750
+ // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing
751
+ // the cache. Hot-reload runs from a synchronous emitter callback, so
752
+ // fire-and-forget here — same async-tolerance as the invalidateAll
753
+ // call below.
754
+ drainWorkerCache(this._workerLlmCache).catch((err) => {
755
+ log({ event: 'config_reload_drain_error', error: String(err) });
756
+ });
757
+ this._lifecycle?.invalidateAll().catch((err) => {
758
+ log({ event: 'config_reload_invalidate_error', error: String(err) });
759
+ });
387
760
  // Apply RAG weight updates
388
761
  if (update.vectorWeight !== undefined ||
389
762
  update.keywordWeight !== undefined) {
@@ -422,9 +795,17 @@ export class SmartServer {
422
795
  resolve({
423
796
  port: actualPort,
424
797
  close: async () => {
798
+ // 1. Stop accepting new connections AND wait for in-flight HTTP
799
+ // requests to drain. Until server.close() resolves, requests
800
+ // accepted before shutdown may still be running and pinning
801
+ // per-session graphs — disposing those graphs first would
802
+ // violate the active-request pinning guarantee.
803
+ await new Promise((res, rej) => server.close((e) => (e ? rej(e) : res())));
804
+ // 2. Now run lifecycle cleanup: sweep timer, lifecycle.disposeAll,
805
+ // config watcher stop, agent close. By this point no HTTP
806
+ // request is in flight, so disposing session graphs is safe.
425
807
  for (const fn of closeFns)
426
808
  await fn();
427
- await new Promise((res, rej) => server.close((e) => (e ? rej(e) : res())));
428
809
  },
429
810
  requestLogger,
430
811
  });
@@ -447,37 +828,93 @@ export class SmartServer {
447
828
  * rejected at config-parse time, so `subCfg.subAgentConfigs` is
448
829
  * always undefined here.
449
830
  */
450
- async buildSubAgent(name, subCfg, parentLogger, embedderFactories) {
451
- if (!subCfg.llm?.apiKey && !subCfg.pipeline?.llm?.main) {
831
+ async buildSubAgent(name, subCfg, parentLogger, embedderFactories, injected) {
832
+ // Normalize subagent llm: either flat { provider, apiKey, ... } or a map
833
+ // { main: {...}, planner: {...} }. normalizeLlmConfig wraps flat shape as
834
+ // { main: flat } so downstream code always reads from .main.
835
+ const subLlmMap = normalizeLlmConfig(subCfg.llm);
836
+ const subLlmMain = subLlmMap?.main;
837
+ if (!subLlmMain?.apiKey &&
838
+ subLlmMain?.provider !== 'sap-ai-sdk' &&
839
+ subLlmMain?.provider !== 'ollama' &&
840
+ !subCfg.pipeline?.llm?.main) {
452
841
  throw new Error(`subagent '${name}': LLM API key is required`);
453
842
  }
454
843
  const subPipeline = subCfg.pipeline;
455
- const mainTemp = Number(subPipeline?.llm?.main?.temperature ?? subCfg.llm.temperature ?? 0.7);
456
- const mainLlm = subPipeline?.llm?.main
457
- ? await makeLlm(subPipeline.llm.main, mainTemp)
458
- : await makeLlm({
459
- // ?? 'deepseek' is a TS type-narrowing net only; the config validator
460
- // rejects a missing flat-schema provider before this runs.
461
- provider: subCfg.llm.provider ?? 'deepseek',
462
- apiKey: subCfg.llm.apiKey,
463
- baseURL: subCfg.llm.url,
464
- model: subCfg.llm.model,
465
- }, mainTemp);
844
+ // LLM/embedder clients: when the per-session re-wire injected them, use
845
+ // those cached instances by reference (NEVER reconstruct). Otherwise (the
846
+ // primary build()), build-once via the cache so the global agent build
847
+ // also populates it and later per-session re-wires reuse the SAME
848
+ // instances.
849
+ // Note: a per-worker embedder slot is carried in WorkerLlmSet and the
850
+ // injected record for forward-compat with Task A8/A10 per-session wiring.
851
+ // Today's buildSubAgent does not separately resolve an embedder here —
852
+ // embedders are carried by the worker's own store via makeRag's
853
+ // `injectedEmbedder` — so we ignore the embedder field below.
854
+ // Resolve (build-once or load from cache) the worker's own LLMs +
855
+ // toolsRag/historyRag/mcpClients. The cache is keyed by worker name; the
856
+ // primary build() populates it (no `injected` arg), and per-session
857
+ // re-wires (`injected` set) read from it via the same call below — the
858
+ // cache hit short-circuits all factories. This keeps the worker's
859
+ // declared RAG/MCP intact across per-session re-wires (review HIGH #1).
860
+ const subFlatLlm = subLlmMain;
861
+ const mainTemp = Number(subPipeline?.llm?.main?.temperature ?? subFlatLlm?.temperature ?? 0.7);
466
862
  const classifierTemp = Number(subPipeline?.llm?.classifier?.temperature ??
467
- subCfg.llm.classifierTemperature ??
863
+ subFlatLlm?.classifierTemperature ??
468
864
  0.1);
469
- const classifierLlm = subPipeline?.llm?.classifier
470
- ? await makeLlm(subPipeline.llm.classifier, classifierTemp)
471
- : subPipeline?.llm?.main
472
- ? await makeLlm(subPipeline.llm.main, classifierTemp)
473
- : await makeLlm({
474
- // ?? 'deepseek' is a TS type-narrowing net only; the config validator
475
- // rejects a missing flat-schema provider before this runs.
476
- provider: subCfg.llm.provider ?? 'deepseek',
477
- apiKey: subCfg.llm.apiKey,
478
- baseURL: subCfg.llm.url,
479
- model: subCfg.llm.model,
480
- }, classifierTemp);
865
+ const cached = await resolveWorkerLlmSet({
866
+ name,
867
+ cache: this._workerLlmCache,
868
+ // Preserve the existing makeLlm derivation exactly.
869
+ makeMain: () => subPipeline?.llm?.main
870
+ ? makeLlm(subPipeline.llm.main, mainTemp)
871
+ : makeLlm({
872
+ // ?? 'deepseek' is a TS type-narrowing net only; the config
873
+ // validator rejects a missing flat-schema provider before
874
+ // this runs.
875
+ provider: subFlatLlm?.provider ?? 'deepseek',
876
+ apiKey: subFlatLlm?.apiKey ?? '',
877
+ baseURL: subFlatLlm?.url,
878
+ model: subFlatLlm?.model,
879
+ }, mainTemp),
880
+ makeClassifier: () => subPipeline?.llm?.classifier
881
+ ? makeLlm(subPipeline.llm.classifier, classifierTemp)
882
+ : subPipeline?.llm?.main
883
+ ? makeLlm(subPipeline.llm.main, classifierTemp)
884
+ : makeLlm({
885
+ provider: subFlatLlm?.provider ?? 'deepseek',
886
+ apiKey: subFlatLlm?.apiKey ?? '',
887
+ baseURL: subFlatLlm?.url,
888
+ model: subFlatLlm?.model,
889
+ }, classifierTemp),
890
+ makeHelper: subPipeline?.llm?.helper
891
+ ? ((h) => () => makeLlm(h, Number(h.temperature ?? 0.1)))(subPipeline.llm.helper)
892
+ : undefined,
893
+ // Worker-OWN tools RAG (from subCfg.rag, if declared). Built once;
894
+ // re-wired per-session by reference — never re-vectorized.
895
+ makeToolsRag: subCfg.rag
896
+ ? () => makeRag(subCfg.rag, {
897
+ injectedEmbedder: subCfg.embedder,
898
+ extraFactories: embedderFactories,
899
+ })
900
+ : undefined,
901
+ makeHistoryRag: subCfg.rag
902
+ ? () => makeRag({ ...subCfg.rag }, {
903
+ injectedEmbedder: subCfg.embedder,
904
+ extraFactories: embedderFactories,
905
+ })
906
+ : undefined,
907
+ // Worker-OWN MCP clients. DI list (subCfg.mcpClients) wins; otherwise
908
+ // SmartAgentBuilder's own MCP-connect path handles `subCfg.mcp` — we
909
+ // don't pre-build those here (connection is the builder's job and is
910
+ // not safe to invoke twice). The cache stores the DI clients only.
911
+ makeMcpClients: subCfg.mcpClients && subCfg.mcpClients.length > 0
912
+ ? async () => subCfg.mcpClients
913
+ : undefined,
914
+ });
915
+ const mainLlm = cached.mainLlm;
916
+ const classifierLlm = cached.classifierLlm;
917
+ const helperLlm = cached.helperLlm;
481
918
  let subBuilder = new SmartAgentBuilder({
482
919
  mcp: subPipeline?.mcp ?? subCfg.mcp,
483
920
  agent: subCfg.agent,
@@ -488,27 +925,184 @@ export class SmartServer {
488
925
  .withClassifierLlm(classifierLlm)
489
926
  .withLogger(parentLogger)
490
927
  .withMode(subCfg.mode ?? 'smart');
491
- if (subPipeline?.llm?.helper) {
492
- const helperLlm = await makeLlm(subPipeline.llm.helper, Number(subPipeline.llm.helper.temperature ?? 0.1));
928
+ if (helperLlm) {
493
929
  subBuilder = subBuilder.withHelperLlm(helperLlm);
494
930
  }
495
- if (subCfg.rag) {
496
- const ragOptions = {
497
- injectedEmbedder: subCfg.embedder,
498
- extraFactories: embedderFactories,
499
- };
500
- subBuilder = subBuilder.setToolsRag(await makeRag(subCfg.rag, ragOptions));
501
- subBuilder = subBuilder.setHistoryRag(await makeRag({ ...subCfg.rag }, ragOptions));
931
+ // SHARE the parent RAG registry + session logger when injected (per-session
932
+ // worker re-wire). The per-call scope filter isolates by ctx.sessionId.
933
+ const sharedReg = resolveSubAgentRagRegistry({
934
+ parentRagRegistry: injected?.ragRegistry,
935
+ });
936
+ if (sharedReg)
937
+ subBuilder = subBuilder.setRagRegistry(sharedReg);
938
+ if (injected?.requestLogger) {
939
+ subBuilder = subBuilder.withRequestLogger(injected.requestLogger);
940
+ }
941
+ // Tools/History RAG priority (review HIGH #1):
942
+ // 1) worker's OWN cached toolsRag (from subCfg.rag) — built once, reused
943
+ // by reference across per-session re-wires (never re-vectorized);
944
+ // 2) parent's injected toolsRag (fallback for workers that did not
945
+ // declare their own store).
946
+ // History RAG: only when the worker has its own cached instance — the
947
+ // parent's history RAG is owned by the parent agent and is not shared.
948
+ if (cached.toolsRag) {
949
+ subBuilder = subBuilder.setToolsRag(cached.toolsRag);
950
+ if (cached.historyRag) {
951
+ subBuilder = subBuilder.setHistoryRag(cached.historyRag);
952
+ }
953
+ }
954
+ else if (injected?.toolsRag) {
955
+ subBuilder = subBuilder.setToolsRag(injected.toolsRag);
502
956
  }
503
957
  if (subCfg.skillManager) {
504
958
  subBuilder = subBuilder.withSkillManager(subCfg.skillManager);
505
959
  }
506
- if (subCfg.mcpClients && subCfg.mcpClients.length > 0) {
507
- subBuilder = subBuilder.withMcpClients(subCfg.mcpClients);
960
+ // MCP clients priority (review HIGH #1):
961
+ // 1) worker's OWN cached MCP clients (from subCfg.mcpClients DI) — keeps
962
+ // the worker pointed at its own upstream when the parent has none;
963
+ // 2) parent's injected GLOBAL MCP clients (fallback) — skips re-connect.
964
+ // If neither is set, fall through to the builder's own MCP-connect path
965
+ // (which honours `subCfg.mcp`).
966
+ if (cached.mcpClients && cached.mcpClients.length > 0) {
967
+ subBuilder = subBuilder.withMcpClients(cached.mcpClients);
968
+ }
969
+ else if (injected?.mcpClients && injected.mcpClients.length > 0) {
970
+ subBuilder = subBuilder.withMcpClients(injected.mcpClients);
508
971
  }
509
972
  const handle = await subBuilder.build();
973
+ // Backfill the per-worker cache from the BUILT handle (review HIGH #7).
974
+ // Only runs on the primary build path (no `injected`) so per-session
975
+ // re-wires never overwrite the cache. See backfillWorkerCacheFromHandle's
976
+ // doc-comment for the rationale.
977
+ if (!injected) {
978
+ const entry = this._workerLlmCache.get(name);
979
+ if (entry)
980
+ await backfillWorkerCacheFromHandle(entry, handle);
981
+ }
982
+ return handle.agent;
983
+ }
984
+ /**
985
+ * Builds a per-session SmartAgent from injected globals (review HIGH #1 +
986
+ * MEDIUM #3). Re-wires the SubAgent registry + DAG coordinator deps FRESH per
987
+ * session, sharing this session's logger + the global ragRegistry/toolsRag/
988
+ * mcpClients + the CACHED per-worker LLM/embedder (this._workerLlmCache). It
989
+ * NEVER reuses the primary build()'s global `registry`/DAG-deps and NEVER
990
+ * constructs new LLM clients.
991
+ */
992
+ async buildSessionAgent(parts) {
993
+ // Guard: globals must already be captured by the primary build() before any
994
+ // session graph is built (the registry calls this lazily on first acquire).
995
+ if (!this._mainLlm || !this._classifierLlm || !this._fileLogger) {
996
+ throw new Error('buildSessionAgent invoked before primary build() captured globals');
997
+ }
998
+ let b = new SmartAgentBuilder({
999
+ agent: this.cfg.agent,
1000
+ prompts: this.cfg.prompts,
1001
+ skipModelValidation: this.cfg.skipModelValidation,
1002
+ })
1003
+ .withMainLlm(this._mainLlm)
1004
+ .withClassifierLlm(this._classifierLlm)
1005
+ .withLogger(this._fileLogger)
1006
+ .withMode(this.cfg.mode ?? 'smart')
1007
+ .withMcpClients(parts.mcpClients) // skips connect + re-vectorize
1008
+ .setRagRegistry(parts.ragRegistry)
1009
+ .withRequestLogger(parts.logger); // per-session token-logger
1010
+ if (this._helperLlm)
1011
+ b = b.withHelperLlm(this._helperLlm);
1012
+ if (parts.toolsRag)
1013
+ b = b.setToolsRag(parts.toolsRag);
1014
+ // FRESH per-session workers — re-wire the registry from the SAME subagent
1015
+ // configs the primary build() used, injecting globals + this session's
1016
+ // logger + the CACHED per-worker LLM/embedder. No LLM reconstruction.
1017
+ if (this.cfg.subAgentConfigs && this.cfg.subAgentConfigs.length > 0) {
1018
+ const registry = new Map();
1019
+ for (const sub of this.cfg.subAgentConfigs) {
1020
+ // Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
1021
+ // clears `_workerLlmCache`, the next buildSessionAgent used to throw
1022
+ // "worker LLM set not cached" because the cache was assumed
1023
+ // pre-populated by the primary build(). buildSubAgent itself routes
1024
+ // through `resolveWorkerLlmSet` which is build-on-miss, so calling
1025
+ // it without an `injected` arg rebuilds the cache entry. We then
1026
+ // re-read the entry to honour the per-worker slot priority below.
1027
+ if (!this._workerLlmCache.has(sub.name)) {
1028
+ await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
1029
+ }
1030
+ const cached = this._workerLlmCache.get(sub.name);
1031
+ if (!cached) {
1032
+ // Defence in depth — should be impossible after the lazy build
1033
+ // above unless buildSubAgent's contract changes.
1034
+ throw new Error(`worker LLM set not cached for '${sub.name}'`);
1035
+ }
1036
+ // Per-worker injected slot priority (review HIGH #7):
1037
+ // worker-cached (from the primary build, includes backfilled
1038
+ // subCfg.mcp / subCfg.rag results) → parent's session-scoped
1039
+ // fallback. Encoded HERE so buildSubAgent does not need to know
1040
+ // the difference; it just consumes injected.mcpClients/toolsRag.
1041
+ const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
1042
+ ? cached.mcpClients
1043
+ : parts.mcpClients;
1044
+ const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
1045
+ const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
1046
+ ragRegistry: parts.ragRegistry,
1047
+ toolsRag: injectedToolsRag,
1048
+ mcpClients: injectedMcpClients,
1049
+ requestLogger: parts.logger,
1050
+ mainLlm: cached.mainLlm,
1051
+ classifierLlm: cached.classifierLlm,
1052
+ helperLlm: cached.helperLlm,
1053
+ embedder: cached.embedder,
1054
+ });
1055
+ registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
1056
+ description: sub.description,
1057
+ }));
1058
+ }
1059
+ b = b.withSubAgents(registry);
1060
+ if (this._dagCoordinatorTemplate) {
1061
+ const tpl = this._dagCoordinatorTemplate;
1062
+ const workers = new Map([...registry].filter(([name]) => name !== tpl.oracleName));
1063
+ const raw = tpl.oracleName ? registry.get(tpl.oracleName) : undefined;
1064
+ b = b.withDagCoordinator({
1065
+ ...tpl.deps,
1066
+ workers,
1067
+ stateOracle: raw ? new SubAgentStateOracle(raw) : undefined,
1068
+ });
1069
+ }
1070
+ }
1071
+ const handle = await b.build();
510
1072
  return handle.agent;
511
1073
  }
1074
+ /**
1075
+ * Resolve identity (mint cookie when needed), acquire the per-session graph,
1076
+ * run `fn` pinned, and release in `finally`. Order in `finally`:
1077
+ * 1. drop the per-traceId logger delta (server-owned free, review HIGH #2)
1078
+ * 2. release the refcount pin
1079
+ */
1080
+ async _withSession(req, res, fn) {
1081
+ const lifecycle = this._lifecycle;
1082
+ if (!lifecycle) {
1083
+ throw new Error('SmartServer lifecycle not initialized');
1084
+ }
1085
+ const traceId = randomUUID();
1086
+ const isHttps = req.socket.encrypted === true ||
1087
+ req.headers['x-forwarded-proto'] === 'https';
1088
+ const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1089
+ const sessionId = resolved.identity.sessionId;
1090
+ if (resolved.minted && resolved.setCookie) {
1091
+ res.setHeader('Set-Cookie', resolved.setCookie);
1092
+ }
1093
+ const graph = await lifecycle.acquire(sessionId);
1094
+ try {
1095
+ await fn(graph, sessionId, traceId);
1096
+ }
1097
+ finally {
1098
+ graph.logger.dropRequest(traceId);
1099
+ // Pass the graph instance — `invalidateAll()` may have detached this
1100
+ // graph into the draining map while the request was in flight; we must
1101
+ // release THIS specific instance, not whatever currently lives under
1102
+ // `sessionId` in the registry.
1103
+ lifecycle.release(sessionId, graph);
1104
+ }
1105
+ }
512
1106
  async _handle(req, res, requestLogger, smartAgent, chat, streamChat, log, healthChecker, modelProvider, adapterMap) {
513
1107
  const rawUrl = req.url ?? '/';
514
1108
  const urlPath = rawUrl.split('?')[0].replace(/\/$/, '') || '/';
@@ -581,8 +1175,27 @@ export class SmartServer {
581
1175
  return;
582
1176
  }
583
1177
  if (req.method === 'GET' && urlPath === '/v1/usage') {
584
- res.writeHead(200, { 'Content-Type': 'application/json' });
585
- res.end(JSON.stringify(requestLogger.getSummary()));
1178
+ const lifecycle = this._lifecycle;
1179
+ if (!lifecycle) {
1180
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1181
+ res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1182
+ return;
1183
+ }
1184
+ const isHttps = req.socket.encrypted === true ||
1185
+ req.headers['x-forwarded-proto'] === 'https';
1186
+ const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1187
+ if (resolved.minted && resolved.setCookie) {
1188
+ res.setHeader('Set-Cookie', resolved.setCookie);
1189
+ }
1190
+ const sessionId = resolved.identity.sessionId;
1191
+ const graph = await lifecycle.acquire(sessionId);
1192
+ try {
1193
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1194
+ res.end(JSON.stringify(graph.logger.getSummary()));
1195
+ }
1196
+ finally {
1197
+ lifecycle.release(sessionId, graph);
1198
+ }
586
1199
  return;
587
1200
  }
588
1201
  // /v1/config or /config
@@ -622,18 +1235,22 @@ export class SmartServer {
622
1235
  res.end(jsonError('Anthropic adapter not registered', 'not_found'));
623
1236
  return;
624
1237
  }
625
- await this._handleAdapterRequest(req, res, smartAgent, anthropicAdapter);
1238
+ await this._withSession(req, res, async (graph, sessionId, traceId) => {
1239
+ await this._handleAdapterRequest(req, res, graph.agent ?? smartAgent, anthropicAdapter, { sessionId, traceId, graph });
1240
+ });
626
1241
  return;
627
1242
  }
628
1243
  if (req.method === 'POST' &&
629
1244
  (urlPath === '/v1/chat/completions' || urlPath === '/chat/completions')) {
630
- await this._handleChat(req, res, requestLogger, smartAgent, chat, streamChat, log, modelProvider);
1245
+ await this._withSession(req, res, async (graph, sessionId, traceId) => {
1246
+ await this._handleChat(req, res, requestLogger, graph.agent ?? smartAgent, chat, streamChat, log, modelProvider, { sessionId, traceId, graph });
1247
+ });
631
1248
  return;
632
1249
  }
633
1250
  res.writeHead(404, { 'Content-Type': 'application/json' });
634
1251
  res.end(jsonError(`Cannot ${req.method} ${urlPath}`, 'invalid_request_error'));
635
1252
  }
636
- async _handleAdapterRequest(req, res, agent, adapter) {
1253
+ async _handleAdapterRequest(req, res, agent, adapter, session) {
637
1254
  const raw = await readBody(req);
638
1255
  let body;
639
1256
  try {
@@ -656,13 +1273,22 @@ export class SmartServer {
656
1273
  }
657
1274
  throw err;
658
1275
  }
1276
+ const augmentedOptions = session
1277
+ ? {
1278
+ ...normalized.options,
1279
+ sessionId: session.sessionId,
1280
+ trace: { traceId: session.traceId },
1281
+ toolAvailability: session.graph.toolAvailability,
1282
+ pendingToolResults: session.graph.pendingToolResults,
1283
+ }
1284
+ : normalized.options;
659
1285
  if (normalized.stream) {
660
1286
  res.writeHead(200, {
661
1287
  'Content-Type': 'text/event-stream',
662
1288
  'Cache-Control': 'no-cache',
663
1289
  Connection: 'keep-alive',
664
1290
  });
665
- for await (const event of adapter.transformStream(agent.streamProcess(normalized.messages, normalized.options), normalized.context)) {
1291
+ for await (const event of adapter.transformStream(agent.streamProcess(normalized.messages, augmentedOptions), normalized.context)) {
666
1292
  const eventLine = event.event ? `event: ${event.event}\n` : '';
667
1293
  res.write(`${eventLine}data: ${event.data}\n\n`);
668
1294
  }
@@ -670,7 +1296,7 @@ export class SmartServer {
670
1296
  return;
671
1297
  }
672
1298
  // Non-streaming
673
- const result = await agent.process(normalized.messages, normalized.options);
1299
+ const result = await agent.process(normalized.messages, augmentedOptions);
674
1300
  res.setHeader('Content-Type', 'application/json');
675
1301
  if (!result.ok) {
676
1302
  res.writeHead(500);
@@ -685,7 +1311,7 @@ export class SmartServer {
685
1311
  res.writeHead(200);
686
1312
  res.end(JSON.stringify(adapter.formatResult(result.value, normalized.context)));
687
1313
  }
688
- async _handleChat(req, res, _requestLogger, smartAgent, _chat, _streamChat, log, modelProvider) {
1314
+ async _handleChat(req, res, _requestLogger, smartAgent, _chat, _streamChat, log, modelProvider, session) {
689
1315
  const rawBody = await readBody(req);
690
1316
  let parsed;
691
1317
  try {
@@ -725,8 +1351,13 @@ export class SmartServer {
725
1351
  res.end(jsonError('at least one message with role "user" is required', 'invalid_request_error'));
726
1352
  return;
727
1353
  }
728
- const traceId = randomUUID();
729
- const sessionId = req.headers['x-session-id'] || 'default';
1354
+ // Prefer the session injected by `_withSession` (cookie identity); fall
1355
+ // back to the legacy x-session-id header / 'default' bucket only when no
1356
+ // session was wired (defensive — production routes always inject one).
1357
+ const traceId = session?.traceId ?? randomUUID();
1358
+ const sessionId = session?.sessionId ??
1359
+ req.headers['x-session-id'] ??
1360
+ 'default';
730
1361
  const sessionLogger = new SessionLogger(this.cfg.logDir || null, sessionId, traceId);
731
1362
  const toolsValidationMode = this.cfg.agent?.externalToolsValidationMode ?? 'permissive';
732
1363
  const externalToolsValidation = normalizeAndValidateExternalTools(body.tools);
@@ -761,6 +1392,12 @@ export class SmartServer {
761
1392
  trace: { traceId },
762
1393
  sessionLogger,
763
1394
  model: body.model,
1395
+ ...(session
1396
+ ? {
1397
+ toolAvailability: session.graph.toolAvailability,
1398
+ pendingToolResults: session.graph.pendingToolResults,
1399
+ }
1400
+ : {}),
764
1401
  ...(body.temperature !== undefined
765
1402
  ? { temperature: body.temperature }
766
1403
  : {}),
@@ -1085,9 +1722,48 @@ export class SmartServer {
1085
1722
  // --- All validation passed — apply mutations ---
1086
1723
  if (resolvedModels) {
1087
1724
  smartAgent.reconfigure(resolvedModels);
1725
+ // Mirror onto the hoisted globals consumed by `buildSessionAgent` so
1726
+ // freshly-built session graphs pick up the new LLMs by reference
1727
+ // (otherwise `this._mainLlm` etc. would keep pointing at the originals
1728
+ // captured during `start()`).
1729
+ if (resolvedModels.mainLlm)
1730
+ this._mainLlm = resolvedModels.mainLlm;
1731
+ if (resolvedModels.classifierLlm)
1732
+ this._classifierLlm = resolvedModels.classifierLlm;
1733
+ if (resolvedModels.helperLlm)
1734
+ this._helperLlm = resolvedModels.helperLlm;
1088
1735
  }
1089
1736
  if (body.agent) {
1090
- smartAgent.applyConfigUpdate(body.agent);
1737
+ const patch = body.agent;
1738
+ smartAgent.applyConfigUpdate(patch);
1739
+ // Mirror onto `this.cfg.agent` so freshly-built session graphs (which
1740
+ // read `this.cfg.agent` in `buildSessionAgent`) observe the update.
1741
+ // Deep-merge to preserve untouched startup fields; replacing the whole
1742
+ // `agent` block would drop YAML defaults the validator already applied.
1743
+ const merged = {
1744
+ ...(this.cfg.agent ?? {}),
1745
+ ...patch,
1746
+ };
1747
+ this.cfg.agent = merged;
1748
+ }
1749
+ // Invalidate per-session SmartAgents + the worker-LLM cache so the next
1750
+ // request mints a session graph that observes the just-applied config.
1751
+ // Without this, chat routes dispatch to `graph.agent` (the per-session
1752
+ // SmartAgent) which was built with the OLD config, and the PUT is a
1753
+ // no-op from the consumer's perspective. Failures are non-fatal so the
1754
+ // 200 response isn't blocked by a dispose hiccup.
1755
+ if (resolvedModels || body.agent) {
1756
+ // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing the
1757
+ // cache so MCP clients owned by the discarded handles disconnect.
1758
+ await drainWorkerCache(this._workerLlmCache);
1759
+ try {
1760
+ await this._lifecycle?.invalidateAll();
1761
+ }
1762
+ catch {
1763
+ // Swallow: cleanup errors must not turn a successful config update
1764
+ // into a 500. The next request will still get a fresh build because
1765
+ // `_workerLlmCache` is already cleared and dispose is idempotent.
1766
+ }
1091
1767
  }
1092
1768
  // --- Return updated config ---
1093
1769
  const models = smartAgent.getActiveConfig();