@mcp-abap-adt/llm-agent-server 16.2.0 → 18.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.
Files changed (35) hide show
  1. package/dist/generated/version.d.ts +1 -1
  2. package/dist/generated/version.js +1 -1
  3. package/dist/smart-agent/build-dag-coordinator-deps.d.ts +34 -0
  4. package/dist/smart-agent/build-dag-coordinator-deps.d.ts.map +1 -0
  5. package/dist/smart-agent/build-dag-coordinator-deps.js +107 -0
  6. package/dist/smart-agent/build-dag-coordinator-deps.js.map +1 -0
  7. package/dist/smart-agent/build-stepper-root.d.ts +97 -0
  8. package/dist/smart-agent/build-stepper-root.d.ts.map +1 -0
  9. package/dist/smart-agent/build-stepper-root.js +242 -0
  10. package/dist/smart-agent/build-stepper-root.js.map +1 -0
  11. package/dist/smart-agent/config.d.ts +207 -3
  12. package/dist/smart-agent/config.d.ts.map +1 -1
  13. package/dist/smart-agent/config.js +465 -39
  14. package/dist/smart-agent/config.js.map +1 -1
  15. package/dist/smart-agent/jsonl-knowledge-backend.d.ts +19 -0
  16. package/dist/smart-agent/jsonl-knowledge-backend.d.ts.map +1 -0
  17. package/dist/smart-agent/jsonl-knowledge-backend.js +46 -0
  18. package/dist/smart-agent/jsonl-knowledge-backend.js.map +1 -0
  19. package/dist/smart-agent/session-identity-resolver.d.ts +17 -0
  20. package/dist/smart-agent/session-identity-resolver.d.ts.map +1 -0
  21. package/dist/smart-agent/session-identity-resolver.js +37 -0
  22. package/dist/smart-agent/session-identity-resolver.js.map +1 -0
  23. package/dist/smart-agent/session-meta-store.d.ts +53 -0
  24. package/dist/smart-agent/session-meta-store.d.ts.map +1 -0
  25. package/dist/smart-agent/session-meta-store.js +36 -0
  26. package/dist/smart-agent/session-meta-store.js.map +1 -0
  27. package/dist/smart-agent/smart-server.d.ts +288 -4
  28. package/dist/smart-agent/smart-server.d.ts.map +1 -1
  29. package/dist/smart-agent/smart-server.js +1325 -111
  30. package/dist/smart-agent/smart-server.js.map +1 -1
  31. package/dist/smart-agent/stepper-coordinator-handler.d.ts +36 -0
  32. package/dist/smart-agent/stepper-coordinator-handler.d.ts.map +1 -0
  33. package/dist/smart-agent/stepper-coordinator-handler.js +214 -0
  34. package/dist/smart-agent/stepper-coordinator-handler.js.map +1 -0
  35. package/package.json +26 -26
@@ -4,10 +4,12 @@
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, InMemoryKnowledgeBackend, KnowledgeRag, makeLlm, SessionGraphFactory, SessionLogger, SessionRegistry, SmartAgentBuilder, SmartAgentSubAgent, SubAgentStateOracle, } from '@mcp-abap-adt/llm-agent-libs';
8
+ import { MCPClientWrapper, McpClientAdapter, } from '@mcp-abap-adt/llm-agent-mcp';
8
9
  import { makeRag } from '@mcp-abap-adt/llm-agent-rag';
9
10
  import { PACKAGE_VERSION } from '../generated/version.js';
10
11
  import { resolveAgentEmbedder, resolveToolsStoreEmbedder, } from './resolve-agent-embedder.js';
12
+ import { resolveSessionIdentity } from './session-identity-resolver.js';
11
13
  // ---------------------------------------------------------------------------
12
14
  // Helpers
13
15
  // ---------------------------------------------------------------------------
@@ -65,11 +67,438 @@ const CORS_HEADERS = {
65
67
  // ---------------------------------------------------------------------------
66
68
  // SmartServer
67
69
  // ---------------------------------------------------------------------------
68
- import { resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorPlanning, resolveToolSelectionStrategy, } from './config.js';
70
+ import { buildDagCoordinatorDeps } from './build-dag-coordinator-deps.js';
71
+ import { buildStepperRoot } from './build-stepper-root.js';
72
+ import { assertCoordinatorConfigShape, normalizeLlmConfig, parseStepperCoordinatorConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorDispatchKind, resolveCoordinatorPlanning, resolveLlmConfig, resolveLlmConfigStrict, resolveToolSelectionStrategy, } from './config.js';
73
+ import { JsonlKnowledgeBackend } from './jsonl-knowledge-backend.js';
74
+ import { InMemorySessionMetaStore } from './session-meta-store.js';
75
+ import { StepperCoordinatorHandler } from './stepper-coordinator-handler.js';
69
76
  export { generateConfigTemplate, loadYamlConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorPlanning, resolveEnvVars, resolveSmartServerConfig, resolveToolSelectionStrategy, YAML_TEMPLATE, } from './config.js';
77
+ /**
78
+ * Drain every cached worker's `close` (if any), then clear the cache map.
79
+ * Used by config-reload (PUT /v1/config + hot-reload — Fix #14/18/21) and by
80
+ * server `close()` to release per-worker MCP connections that were attached
81
+ * to the discarded `SmartAgentHandle`s.
82
+ *
83
+ * IMPORTANT — in-flight caveat: this aborts any request that is mid-call on
84
+ * a worker's MCP client. That is acceptable for an admin action (config
85
+ * reload, server shutdown) where the alternative is leaking connections.
86
+ * Server `close()` calls this AFTER `lifecycle.disposeAll()` so per-session
87
+ * graphs that reference worker clients are torn down first.
88
+ *
89
+ * Uses `Promise.allSettled` so one failing close cannot block the others.
90
+ */
91
+ export async function drainWorkerCache(cache) {
92
+ const closers = [];
93
+ for (const entry of cache.values()) {
94
+ if (entry.close) {
95
+ try {
96
+ closers.push(entry.close());
97
+ }
98
+ catch {
99
+ // sync throw (defensive — close is async by contract)
100
+ }
101
+ }
102
+ }
103
+ cache.clear();
104
+ if (closers.length > 0) {
105
+ await Promise.allSettled(closers);
106
+ }
107
+ }
108
+ /**
109
+ * Build-once-per-worker resolver. The first time a worker name is seen, it
110
+ * constructs the worker's main/classifier/(optional helper) LLM + embedder and
111
+ * caches the set; every later call (e.g. each per-session worker re-wire)
112
+ * returns the SAME set by reference — never reconstructing LLM clients
113
+ * (locked invariant: LLM/embedder clients are global, built once).
114
+ *
115
+ * Accepts optional `makeToolsRag`/`makeHistoryRag`/`makeMcpClients` factories;
116
+ * when provided, the resolver builds them ONCE on the first miss and caches
117
+ * them on the returned set. Subsequent calls return the cached resources by
118
+ * reference — never re-vectorizing or re-connecting MCP.
119
+ */
120
+ export async function resolveWorkerLlmSet(input) {
121
+ const hit = input.cache.get(input.name);
122
+ if (hit)
123
+ return hit;
124
+ const mainLlm = await input.makeMain();
125
+ const classifierLlm = await input.makeClassifier();
126
+ const helperLlm = input.makeHelper ? await input.makeHelper() : undefined;
127
+ const embedder = input.makeEmbedder ? await input.makeEmbedder() : undefined;
128
+ const toolsRag = input.makeToolsRag ? await input.makeToolsRag() : undefined;
129
+ const historyRag = input.makeHistoryRag
130
+ ? await input.makeHistoryRag()
131
+ : undefined;
132
+ const mcpClients = input.makeMcpClients
133
+ ? await input.makeMcpClients()
134
+ : undefined;
135
+ const set = {
136
+ mainLlm,
137
+ classifierLlm,
138
+ helperLlm,
139
+ embedder,
140
+ toolsRag,
141
+ historyRag,
142
+ mcpClients,
143
+ };
144
+ input.cache.set(input.name, set);
145
+ return set;
146
+ }
147
+ /**
148
+ * Backfill the per-worker cache entry from the BUILT handle (review HIGH #7).
149
+ *
150
+ * The primary `buildSubAgent` populates `cached.mcpClients`/`toolsRag`/
151
+ * `historyRag` only when the worker config provided DI factories. Workers
152
+ * configured with `subCfg.mcp: ...` (regular config that triggers the
153
+ * builder's own auto-connect) or with `subCfg.rag: ...` whose RAG is owned
154
+ * by the builder leave those slots empty — so per-session re-wires would
155
+ * fall back to the PARENT's MCP/RAG, losing the worker's own connection.
156
+ *
157
+ * After the builder finishes, this helper captures what the handle actually
158
+ * holds and stores it BY REFERENCE on the cache entry. Subsequent per-session
159
+ * re-wires read the same slots and find the worker's own resources.
160
+ *
161
+ * Pure helper, mutates `entry` in place. No-op when the corresponding slot
162
+ * is already populated (DI path wins) or when the handle has no resource for
163
+ * that slot (worker simply didn't declare one).
164
+ */
165
+ export async function backfillWorkerCacheFromHandle(entry, handle) {
166
+ if ((!entry.mcpClients || entry.mcpClients.length === 0) &&
167
+ handle.mcpClients &&
168
+ handle.mcpClients.length > 0) {
169
+ entry.mcpClients = handle.mcpClients;
170
+ }
171
+ if (!entry.toolsRag) {
172
+ const t = handle.ragRegistry.get('tools');
173
+ if (t)
174
+ entry.toolsRag = t;
175
+ }
176
+ if (!entry.historyRag) {
177
+ const h = handle.ragRegistry.get('history');
178
+ if (h)
179
+ entry.historyRag = h;
180
+ }
181
+ // Capture the per-worker shutdown function (Fix #21). If the entry already
182
+ // had a close from a previous build (e.g. the same worker name was rebuilt
183
+ // WITHOUT going through `drainWorkerCache` first — defence in depth), await
184
+ // the prior close before overwriting so its MCP connections do not leak.
185
+ if (handle.close) {
186
+ if (entry.close) {
187
+ try {
188
+ await entry.close();
189
+ }
190
+ catch {
191
+ // Best-effort; never block the new build on a stale close failure.
192
+ }
193
+ }
194
+ entry.close = handle.close;
195
+ }
196
+ }
197
+ /**
198
+ * Share the parent RAG registry with subagents (per-session worker re-wire).
199
+ * Session/user/global collections written at the top level become visible to
200
+ * workers; the per-call scope filter (`rag-query.ts`) isolates by
201
+ * `ctx.sessionId` / `ctx.options.userId`. A worker's own declared store is
202
+ * registered INTO this same registry under its namespace. When the parent
203
+ * registry is undefined (no top-level registry yet — e.g. unit test seam),
204
+ * return undefined so the builder allocates its own SimpleRagRegistry.
205
+ */
206
+ export function resolveSubAgentRagRegistry(input) {
207
+ return input.parentRagRegistry;
208
+ }
209
+ /**
210
+ * Composes the cookie identity resolver + SessionGraphFactory + SessionRegistry
211
+ * into one lifecycle object the server's `_handle` consumes. The default MCP
212
+ * factory returns the shared GLOBAL clients by reference (one upstream
213
+ * connection); a creds-aware build swaps it out (out of scope here).
214
+ */
215
+ export function buildSessionLifecycle(opts) {
216
+ const factory = new SessionGraphFactory({
217
+ mcpClientFactory: (_identity) => opts.mcpClients,
218
+ toolsRag: opts.toolsRag,
219
+ ragRegistry: opts.ragRegistry,
220
+ buildAgent: opts.buildAgent,
221
+ logger: opts.logger,
222
+ });
223
+ const registry = new SessionRegistry({
224
+ idleTtlMs: opts.idleTtlMs,
225
+ maxSessions: opts.maxSessions,
226
+ factory,
227
+ });
228
+ return {
229
+ resolve: (cookieHeader, isHttps) => resolveSessionIdentity({
230
+ cookieHeader,
231
+ cookieName: opts.cookieName,
232
+ maxAgeSeconds: Math.max(1, Math.floor(opts.idleTtlMs / 1000)),
233
+ isHttps,
234
+ }),
235
+ acquire: (sessionId) => registry.acquire(sessionId),
236
+ release: (sessionId, graph) => registry.release(sessionId, graph),
237
+ evictIdle: () => registry.evictIdle(),
238
+ disposeAll: () => registry.disposeAll(),
239
+ invalidateAll: () => registry.invalidateAll(),
240
+ registry,
241
+ };
242
+ }
243
+ /**
244
+ * Seed session-scope guidance entries into a BRAND-NEW session's knowledge-RAG
245
+ * (deployment-supplied tool-usage guidance the planner/executor read in "Known
246
+ * facts"). Idempotent: rehydrates via init() and writes ONLY when the session is
247
+ * empty (`fingerprint() === 'n=0'`), so resumes never duplicate. Entries are
248
+ * config DATA — the runtime stays MCP-agnostic (no tool knowledge in agent code).
249
+ */
250
+ export async function seedSessionKnowledge(kr, seeds, nowIso) {
251
+ if (seeds.length === 0)
252
+ return;
253
+ await kr.init?.();
254
+ if (kr.fingerprint?.() !== 'n=0')
255
+ return; // not a brand-new session → skip
256
+ for (const s of seeds) {
257
+ await kr.write({
258
+ content: s.content,
259
+ metadata: {
260
+ traceId: 'seed',
261
+ turnId: 'seed',
262
+ stepperId: 'seed',
263
+ task: 'session-seed',
264
+ artifactType: s.artifactType,
265
+ createdAt: nowIso,
266
+ },
267
+ });
268
+ }
269
+ }
270
+ /**
271
+ * Record that a request for `sessionId` STARTED — create the meta row on first
272
+ * sight, else touch it and mark in-progress. Called from the live request path
273
+ * (`_withSession`) so GET /v1/sessions, resume and delete actually see sessions
274
+ * produced by normal chat/stream traffic (review Finding 3). `userIdentity` is
275
+ * the sessionId itself in the default no-auth build — matching how the
276
+ * /v1/sessions endpoints resolve identity (`resolved.identity.sessionId`).
277
+ */
278
+ export async function recordSessionStart(store, sessionId, nowIso) {
279
+ const existing = await store.get(sessionId);
280
+ if (!existing) {
281
+ await store.create({
282
+ sessionId,
283
+ userIdentity: sessionId,
284
+ createdAt: nowIso,
285
+ lastUsedAt: nowIso,
286
+ status: 'in-progress',
287
+ });
288
+ return;
289
+ }
290
+ await store.touch(sessionId, nowIso);
291
+ await store.setStatus(sessionId, 'in-progress');
292
+ }
293
+ /**
294
+ * Record that a request for `sessionId` FINISHED — touch + mark idle (so it can
295
+ * be resumed). No-op if the row was deleted mid-flight.
296
+ */
297
+ export async function recordSessionEnd(store, sessionId, nowIso) {
298
+ const existing = await store.get(sessionId);
299
+ if (!existing)
300
+ return;
301
+ await store.touch(sessionId, nowIso);
302
+ await store.setStatus(sessionId, 'idle');
303
+ }
304
+ /**
305
+ * List all sessions for a given user identity.
306
+ * Extracted for unit-testability (mirrors the /v1/usage handler pattern).
307
+ */
308
+ export async function handleListSessions(store, identity) {
309
+ const sessions = await store.listForUser(identity);
310
+ return { sessions };
311
+ }
312
+ /**
313
+ * Resume (claim) a session by ID for a user identity.
314
+ * Sets the session status to 'idle' so it can be re-entered.
315
+ */
316
+ export async function handleResumeSession(store, identity, id) {
317
+ const row = await store.get(id);
318
+ if (!row || row.userIdentity !== identity) {
319
+ return { ok: false, error: 'session not found' };
320
+ }
321
+ await store.setStatus(id, 'idle');
322
+ const updated = await store.get(id);
323
+ return { ok: true, session: updated };
324
+ }
325
+ /**
326
+ * Delete a session by ID for a user identity, and evict its RAG state.
327
+ */
328
+ export async function handleDeleteSession(store, identity, id, evictFn) {
329
+ const row = await store.get(id);
330
+ if (!row || row.userIdentity !== identity) {
331
+ return { ok: false, error: 'session not found' };
332
+ }
333
+ await store.delete(id);
334
+ await evictFn(id);
335
+ return { ok: true };
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // MCP bridge for the Stepper path (B-1)
339
+ // ---------------------------------------------------------------------------
340
+ /**
341
+ * Build a `callMcp(name, args, signal?)` bridge over a list of `IMcpClient`s.
342
+ *
343
+ * Dispatch strategy (mirrors the 17.0 tool-loop):
344
+ * - Iterate the clients; the first client whose `listTools()` contains `name` wins.
345
+ * - On success: return the textual content (stringify structured payloads).
346
+ * - On error: return the error message as a string so the LLM executor can
347
+ * feed the failure back to the model as a tool result (no throw).
348
+ * - If no client owns the tool: return an informative "Tool not found" string.
349
+ *
350
+ * Exported for testability — tests can call this with a fake IMcpClient list.
351
+ */
352
+ /**
353
+ * Connect MCP clients from a YAML `mcp:` config block (single or array).
354
+ *
355
+ * Mirrors the builder's connection logic (builder.ts ~lines 897-920) so the
356
+ * Stepper path gets the same clients that the builder would have connected
357
+ * internally. Exported for testability.
358
+ *
359
+ * @param mcpCfg - single `SmartServerMcpConfig` or array thereof (from
360
+ * `pipeline.mcp` or `this.cfg.mcp`). Accepts the union so callers can pass
361
+ * either directly without pre-normalising.
362
+ */
363
+ /** Deterministic cache key for tool args — stable regardless of key order. */
364
+ function stableArgsKey(args) {
365
+ if (args === null || typeof args !== 'object')
366
+ return JSON.stringify(args);
367
+ if (Array.isArray(args))
368
+ return JSON.stringify(args.map((v) => v));
369
+ const obj = args;
370
+ const sorted = {};
371
+ for (const k of Object.keys(obj).sort())
372
+ sorted[k] = obj[k];
373
+ return JSON.stringify(sorted);
374
+ }
375
+ export async function connectMcpClientsFromConfig(mcpCfg) {
376
+ if (!mcpCfg)
377
+ return [];
378
+ const list = Array.isArray(mcpCfg) ? mcpCfg : [mcpCfg];
379
+ const connected = [];
380
+ for (const cfg of list) {
381
+ let wrapper;
382
+ if (cfg.type === 'stdio') {
383
+ wrapper = new MCPClientWrapper({
384
+ transport: 'stdio',
385
+ command: cfg.command,
386
+ args: cfg.args ?? [],
387
+ });
388
+ }
389
+ else {
390
+ wrapper = new MCPClientWrapper({
391
+ transport: 'auto',
392
+ url: cfg.url,
393
+ headers: cfg.headers,
394
+ });
395
+ }
396
+ await wrapper.connect();
397
+ connected.push(new McpClientAdapter(wrapper));
398
+ }
399
+ return connected;
400
+ }
401
+ export function buildMcpBridge(clients) {
402
+ return async (name, args, _signal) => {
403
+ const safeArgs = args != null && typeof args === 'object' && !Array.isArray(args)
404
+ ? args
405
+ : {};
406
+ for (const client of clients) {
407
+ const listed = await client.listTools();
408
+ if (!listed.ok)
409
+ continue;
410
+ const owns = listed.value.some((t) => t.name === name);
411
+ if (!owns)
412
+ continue;
413
+ const result = await client.callTool(name, safeArgs);
414
+ if (!result.ok) {
415
+ return result.error.message;
416
+ }
417
+ const { content } = result.value;
418
+ return typeof content === 'string' ? content : JSON.stringify(content);
419
+ }
420
+ return `Tool not found: ${name}`;
421
+ };
422
+ }
423
+ // ---------------------------------------------------------------------------
424
+ // Raw-config mode routing gate (R6-F2)
425
+ // ---------------------------------------------------------------------------
426
+ /**
427
+ * Returns `true` ONLY when the raw coordinator config has an explicit `mode`
428
+ * string — the opt-in gate for the 18.0 Stepper runtime.
429
+ *
430
+ * IMPORTANT: Do NOT call `parseStepperCoordinatorConfig` to decide — that
431
+ * function defaults `mode` to `'planned-react'`, which would silently route
432
+ * every 17.0 legacy config onto the Stepper path. Gate on the RAW field
433
+ * presence only; parse is done INSIDE the Stepper branch after this check.
434
+ */
435
+ export function usesStepper(coordCfg) {
436
+ if (!coordCfg)
437
+ return false;
438
+ // Explicit opt-in: `coordinator.mode` (preset alias) — a string in raw config.
439
+ if (typeof coordCfg.mode === 'string')
440
+ return true;
441
+ // OR an explicit `coordinator.flow` composition block (mode-less form). A flow
442
+ // with no mode is still a Stepper config; without this it would silently fall
443
+ // through to the plain smart pipeline.
444
+ if (typeof coordCfg.flow === 'object' && coordCfg.flow !== null)
445
+ return true;
446
+ // Legacy 17.0 configs with `coordinator.planner` but no `coordinator.mode`
447
+ // stay on the deprecated DagCoordinatorHandler (removed in 19.0).
448
+ return false;
449
+ }
70
450
  export class SmartServer {
71
451
  cfg;
72
452
  noop = () => { };
453
+ /**
454
+ * GLOBAL per-worker LLM/embedder cache. Populated lazily by `buildSubAgent`
455
+ * the first time each worker name is seen; subsequent per-session re-wires
456
+ * pull from this cache by reference (never reconstructing LLM clients).
457
+ */
458
+ _workerLlmCache = new Map();
459
+ /** Lifecycle handle wired in `start()`; consumed by `_handle`. */
460
+ _lifecycle;
461
+ /** Hoisted globals used by `buildSessionAgent` to re-wire fresh per-session workers. */
462
+ _mainLlm;
463
+ _classifierLlm;
464
+ _helperLlm;
465
+ _fileLogger;
466
+ _mergedEmbedderFactories;
467
+ /**
468
+ * Captured DAG coordinator template — `deps` are stateless or LLM-bound and
469
+ * are reused across sessions; only `workers` + `stateOracle` are re-wired per
470
+ * session (review HIGH #1).
471
+ */
472
+ _dagCoordinatorTemplate;
473
+ /**
474
+ * 18.0 Stepper coordinator handler — stateless, session context comes through
475
+ * `ctx.sessionId` at execute time. Built once in `start()` when
476
+ * `coordinator.mode` is present in the raw config; reused across sessions.
477
+ */
478
+ _stepperCoordinatorHandler;
479
+ /**
480
+ * MCP clients connected for the Stepper path from the YAML `mcp:` config
481
+ * block. These are connected ONCE in `start()` (lazily resolved by
482
+ * `connectMcpClientsFromConfig`) and reused across every Stepper request.
483
+ *
484
+ * Populated only when `this.cfg.mcp` / `pipeline.mcp` is set AND no
485
+ * DI/plugin clients exist (DI precedence: `this.cfg.mcpClients` > plugin >
486
+ * yaml). Disposed via the server's `closeFns` on shutdown.
487
+ */
488
+ _stepperMcpClients;
489
+ /**
490
+ * The ONE shared knowledge backend for the Stepper path (set during build).
491
+ * Held so DELETE /v1/sessions/:id can evict a session's entries from it —
492
+ * critical for the long-lived in-memory backend, which would otherwise retain
493
+ * knowledge after a delete and rehydrate it on a same-id re-entry.
494
+ */
495
+ _stepperKnowledgeBackend;
496
+ /**
497
+ * Session meta-store for /v1/sessions endpoints (Task 17).
498
+ * Defaults to InMemorySessionMetaStore; a durable store can be injected via
499
+ * `cfg.sessionMetaStore` in a future extension.
500
+ */
501
+ _sessionMetaStore = new InMemorySessionMetaStore();
73
502
  constructor(config) {
74
503
  this.cfg = config;
75
504
  }
@@ -78,38 +507,63 @@ export class SmartServer {
78
507
  const fileLogger = {
79
508
  log: (e) => log(e),
80
509
  };
510
+ this._fileLogger = fileLogger;
81
511
  const pipeline = this.cfg.pipeline;
82
512
  // ---- Composition root: resolve config → interfaces --------------------
83
- // LLM resolution
84
- const mainTemp = Number(pipeline?.llm?.main?.temperature ?? this.cfg.llm.temperature ?? 0.7);
513
+ // LLM resolution — normalize flat/map cfg.llm and derive pipeline fallback.
514
+ const llmMap = normalizeLlmConfig(this.cfg.llm);
515
+ // Adapt pipeline.llm.main (uses `baseURL`) → SmartServerLlmConfig (uses
516
+ // `url`) so resolveLlmConfig's pipelineFallback parameter speaks one
517
+ // shape. Returns undefined when no pipeline.llm.main is configured.
518
+ const pipelineFallback = (() => {
519
+ const pm = pipeline?.llm?.main;
520
+ if (!pm)
521
+ return undefined;
522
+ return {
523
+ provider: pm.provider,
524
+ apiKey: pm.apiKey ?? '',
525
+ url: pm.baseURL,
526
+ model: pm.model,
527
+ temperature: pm.temperature,
528
+ };
529
+ })();
530
+ const topMain = resolveLlmConfig(llmMap, 'main', pipelineFallback);
531
+ const mainTemp = Number(pipeline?.llm?.main?.temperature ?? topMain?.temperature ?? 0.7);
85
532
  const mainLlm = pipeline?.llm?.main
86
533
  ? 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);
534
+ : topMain
535
+ ? await makeLlm({
536
+ provider: topMain.provider ?? 'deepseek',
537
+ apiKey: topMain.apiKey,
538
+ baseURL: topMain.url,
539
+ model: topMain.model,
540
+ }, mainTemp)
541
+ : (() => {
542
+ throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
543
+ })();
95
544
  const classifierTemp = Number(pipeline?.llm?.classifier?.temperature ??
96
- this.cfg.llm.classifierTemperature ??
545
+ topMain?.classifierTemperature ??
97
546
  0.1);
98
547
  const classifierLlm = pipeline?.llm?.classifier
99
548
  ? await makeLlm(pipeline.llm.classifier, classifierTemp)
100
549
  : pipeline?.llm?.main
101
550
  ? 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);
551
+ : topMain
552
+ ? await makeLlm({
553
+ provider: topMain.provider ?? 'deepseek',
554
+ apiKey: topMain.apiKey,
555
+ baseURL: topMain.url,
556
+ model: topMain.model,
557
+ }, classifierTemp)
558
+ : (() => {
559
+ throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
560
+ })();
110
561
  const helperLlm = pipeline?.llm?.helper
111
562
  ? await makeLlm(pipeline.llm.helper, Number(pipeline.llm.helper.temperature ?? 0.1))
112
563
  : undefined;
564
+ this._mainLlm = mainLlm;
565
+ this._classifierLlm = classifierLlm;
566
+ this._helperLlm = helperLlm;
113
567
  // ---- Plugin loader -------------------------------------------------------
114
568
  const pluginLoader = this.cfg.pluginLoader ??
115
569
  (() => {
@@ -143,6 +597,7 @@ export class SmartServer {
143
597
  ...plugins.embedderFactories,
144
598
  ...this.cfg.embedderFactories, // config takes precedence over plugins
145
599
  };
600
+ this._mergedEmbedderFactories = mergedEmbedderFactories;
146
601
  // Resolve the embedder ONCE so the same instance feeds both makeRag and the
147
602
  // subagent context-builder's toolSource (#137). See resolve-agent-embedder.
148
603
  let resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this.cfg.embedder, mergedEmbedderFactories);
@@ -267,8 +722,9 @@ export class SmartServer {
267
722
  // Build SubAgentRegistry from `subagents:` YAML block (if present).
268
723
  // Each sub-agent is a minimal SmartAgent reusing the parent's plugin
269
724
  // outputs (embedder factories, plugins) but with its own LLM/RAG/MCP/etc.
725
+ // Hoisted so the DAG branch below can reuse the same instances.
726
+ const registry = new Map();
270
727
  if (this.cfg.subAgentConfigs && this.cfg.subAgentConfigs.length > 0) {
271
- const registry = new Map();
272
728
  for (const sub of this.cfg.subAgentConfigs) {
273
729
  const subAgent = await this.buildSubAgent(sub.name, sub.config, fileLogger, mergedEmbedderFactories);
274
730
  registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
@@ -285,51 +741,310 @@ export class SmartServer {
285
741
  // ---- Coordinator (autonomous plan-execute loop) ------------------------
286
742
  const coordCfg = this.cfg.coordinatorYaml;
287
743
  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 : [];
744
+ const rawCoordCfg = coordCfg;
745
+ if (usesStepper(rawCoordCfg)) {
746
+ // ── 18.0 Stepper path (opt-in via coordinator.mode) ──────────────────
747
+ // Parse only INSIDE this branch so parseStepperCoordinatorConfig's
748
+ // mode default ('planned-react') never fires for legacy configs (R6-F2).
749
+ const stepperCfg = parseStepperCoordinatorConfig(rawCoordCfg);
750
+ const logDir = this.cfg.logDir;
751
+ // KnowledgeRag factory: ONE backend instance shared across all requests.
752
+ // Both backends are keyed by sessionId internally, so a single instance
753
+ // gives correct per-session isolation AND persistence across same-cookie
754
+ // requests within the process. JsonlKnowledgeBackend (logDir set) adds
755
+ // durability across restarts; InMemoryKnowledgeBackend is the in-process
756
+ // default. (Previously a fresh InMemoryKnowledgeBackend was created per
757
+ // call, so subsequent same-session requests lost prior entries and were
758
+ // re-seeded — review fix.)
759
+ const knowledgeBackend = logDir
760
+ ? new JsonlKnowledgeBackend(logDir)
761
+ : new InMemoryKnowledgeBackend();
762
+ // Held on the instance so DELETE /v1/sessions/:id can evict a session's
763
+ // knowledge from THIS backend (not just remove the JSONL file).
764
+ this._stepperKnowledgeBackend = knowledgeBackend;
765
+ const knowledgeRagFor = async (sessionId) => {
766
+ const kr = new KnowledgeRag(knowledgeBackend, sessionId);
767
+ // Seed deployment-supplied guidance into a BRAND-NEW session only
768
+ // (idempotent on resume). Config DATA — keeps the runtime MCP-agnostic.
769
+ await seedSessionKnowledge(kr, stepperCfg.knowledgeSeed, new Date().toISOString());
770
+ return kr;
771
+ };
772
+ // ToolsRag handle: real adapter over the server's tools RAG store + MCP
773
+ // catalog. Implements IToolsRagHandle for the Stepper runtime:
774
+ // query(text, k) — semantic search via toolsRag+embedder when available;
775
+ // catalog-order fallback when neither is present.
776
+ // lookup(name) — returns the tool schema from the MCP catalog by name
777
+ // (populated lazily on first query()).
778
+ //
779
+ // Catalog note: when DI/plugin clients are present they are already
780
+ // connected. When the config carries a YAML `mcp:` block instead, the
781
+ // builder connects those clients INSIDE builder.build() and never
782
+ // exposes them here. We therefore connect the YAML mcp config ONCE and
783
+ // cache the result in _stepperMcpClients so every subsequent Stepper
784
+ // request reuses the same live connections without reconnecting.
785
+ //
786
+ // DI precedence: this.cfg.mcpClients > plugin clients > yaml mcp config.
787
+ // NOTE: connection is NOT safe to invoke twice on the same wrapper, so
788
+ // the cache guard below is critical — do NOT move this inside a
789
+ // per-request factory.
790
+ if (!mcpClients && !this._stepperMcpClients) {
791
+ const yamlMcp = pipeline?.mcp ?? this.cfg.mcp;
792
+ this._stepperMcpClients = await connectMcpClientsFromConfig(yamlMcp);
312
793
  }
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 });
794
+ const stepperMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
795
+ // Lazily-populated catalog: name → LlmTool.
796
+ let catalogCache;
797
+ const ensureCatalog = async () => {
798
+ if (catalogCache)
799
+ return catalogCache;
800
+ const catalog = new Map();
801
+ await Promise.allSettled(stepperMcpClients.map(async (client) => {
802
+ const result = await client.listTools();
803
+ if (result.ok) {
804
+ for (const t of result.value) {
805
+ if (!catalog.has(t.name)) {
806
+ catalog.set(t.name, t);
807
+ }
808
+ }
809
+ }
810
+ }));
811
+ catalogCache = catalog;
812
+ return catalog;
813
+ };
814
+ const toolsRagHandle = {
815
+ async query(text, k) {
816
+ const limit = k ?? 20;
817
+ const catalog = await ensureCatalog();
818
+ // Semantic path: requires both a vector store and an embedder.
819
+ if (toolsRag && resolvedEmbedder) {
820
+ const embedding = new QueryEmbedding(text, resolvedEmbedder);
821
+ const ragResult = await toolsRag.query(embedding, limit);
822
+ if (ragResult.ok) {
823
+ const hits = [];
824
+ for (const r of ragResult.value) {
825
+ const id = r.metadata.id;
826
+ if (id?.startsWith('tool:')) {
827
+ const name = id.slice(5).replace(/:.*$/, '');
828
+ const tool = catalog.get(name);
829
+ if (tool)
830
+ hits.push(tool);
831
+ }
832
+ }
833
+ if (hits.length > 0)
834
+ return hits;
835
+ }
836
+ }
837
+ // Catalog-order fallback: return first `limit` tools from the MCP catalog.
838
+ return [...catalog.values()].slice(0, limit);
839
+ },
840
+ lookup(name) {
841
+ // Synchronous: returns from the in-memory cache populated by ensureCatalog.
842
+ // Returns undefined before the first query() call — callers that need
843
+ // a schema before any query should call query() first.
844
+ return catalogCache?.get(name);
845
+ },
846
+ };
847
+ // Mint helpers: stable UUIDs per spec §C.1/§C.2.
848
+ // The server wires randomUUID(); tests can inject deterministic minters.
849
+ const mintStepperId = () => randomUUID();
850
+ const mintTurnId = () => randomUUID();
851
+ const stepperMakeLlm = async (lc) => makeLlm({
852
+ provider: lc.provider ?? 'deepseek',
853
+ apiKey: lc.apiKey,
854
+ baseURL: lc.url,
855
+ model: lc.model,
856
+ }, Number(lc.temperature ?? mainTemp));
857
+ // Real callMcp bridge — delegates to the exported buildMcpBridge helper
858
+ // that iterates stepperMcpClients. Exported for testability (B-1).
859
+ const callMcp = buildMcpBridge(stepperMcpClients);
860
+ // DI/test override registry — left empty in production so buildStepperRoot
861
+ // builds the recursive child Steppers itself from `subagents` (below),
862
+ // sharing this run's role LLMs + shared token ledger.
863
+ const stepperRegistry = new Map();
864
+ // Declared subagents (name + description) → advertised to the planner and
865
+ // turned into recursive child Steppers in deep-stepper mode (Finding 1).
866
+ const stepperSubagents = (this.cfg.subAgentConfigs ?? []).map((s) => ({
867
+ name: s.name,
868
+ description: s.description,
869
+ }));
870
+ const stepperHandler = new StepperCoordinatorHandler({
871
+ buildBuilt: async (_ctx, logLlmCall) => {
872
+ // Per-RUN MCP result cache. Identical (tool, args) calls within ONE
873
+ // run reuse the first result instead of re-hitting MCP — fixes the
874
+ // redundant re-reads we saw in flow (gather → analyze → synthesize
875
+ // each fetching the same source/includes) and the latency that
876
+ // caused. Caching the Promise also dedups concurrent identical
877
+ // calls. Fresh per request → never stale across requests.
878
+ const runMcpCache = new Map();
879
+ const cachedCallMcp = (name, args, signal) => {
880
+ const key = `${name}:${stableArgsKey(args)}`;
881
+ const hit = runMcpCache.get(key);
882
+ if (hit)
883
+ return hit;
884
+ const p = callMcp(name, args, signal);
885
+ runMcpCache.set(key, p);
886
+ return p;
887
+ };
888
+ return buildStepperRoot({
889
+ coordCfg: rawCoordCfg,
890
+ registry: stepperRegistry,
891
+ subagents: stepperSubagents,
892
+ makeLlm: stepperMakeLlm,
893
+ knowledgeRagFor: (sid) => {
894
+ const backend = knowledgeBackend ?? new InMemoryKnowledgeBackend();
895
+ return new KnowledgeRag(backend, sid);
896
+ },
897
+ toolsRag: toolsRagHandle,
898
+ callMcp: cachedCallMcp,
899
+ mintStepperId,
900
+ llmMap,
901
+ pipelineFallback,
902
+ logLlmCall,
903
+ });
904
+ },
905
+ knowledgeRagFor,
906
+ toolsRag: toolsRagHandle,
907
+ mintStepperId,
908
+ mintTurnId,
909
+ });
910
+ this._stepperCoordinatorHandler = stepperHandler;
911
+ builder = builder.withStepperCoordinator(stepperHandler);
912
+ log({
913
+ event: 'stepper_coordinator_configured',
914
+ mode: stepperCfg.mode,
915
+ config: rawCoordCfg,
916
+ });
917
+ }
918
+ else if (coordCfg.planner !== undefined) {
919
+ // ── Legacy DAG path (deprecated §K — remove in 19.0) ─────────────────
920
+ log({
921
+ event: 'config_warning',
922
+ message: 'coordinator.planner without coordinator.mode uses the deprecated DagCoordinatorHandler; ' +
923
+ 'set coordinator.mode to adopt the 18.0 Stepper runtime',
924
+ });
925
+ // Fail loud if the config mixes DAG and linear fields.
926
+ assertCoordinatorConfigShape(rawCoordCfg);
927
+ // planner shape/type already validated by assertCoordinatorConfigShape.
928
+ // Validate interpreter.type if present — only 'dag' is supported.
929
+ const interpKind = coordCfg.interpreter?.type;
930
+ if (interpKind !== undefined && interpKind !== 'dag') {
931
+ throw new Error(`coordinator.interpreter: unknown type '${interpKind}' (only 'dag' is supported)`);
932
+ }
933
+ const built = await buildDagCoordinatorDeps({
934
+ coordCfg: rawCoordCfg,
935
+ llmMap,
936
+ pipelineFallback,
937
+ mainLlm,
938
+ helperLlm,
939
+ mainTemp,
940
+ registry,
941
+ makeLlm: async (lc) => makeLlm({
942
+ provider: lc.provider ?? 'deepseek',
943
+ apiKey: lc.apiKey,
944
+ baseURL: lc.url,
945
+ model: lc.model,
946
+ }, Number(lc.temperature ?? mainTemp)),
947
+ warn: (m) => log({ event: 'config_warning', message: m }),
948
+ });
949
+ // built is defined when coordCfg.planner !== undefined.
950
+ if (!built) {
951
+ throw new Error('internal: buildDagCoordinatorDeps returned undefined despite planner present');
952
+ }
953
+ const { oracleName, ...deps } = built;
954
+ builder = builder.withDagCoordinator(deps);
955
+ // Capture template for per-session re-wire (workers + stateOracle are
956
+ // re-wired per session; everything else is stateless or LLM-bound).
957
+ this._dagCoordinatorTemplate = {
958
+ deps: {
959
+ planner: deps.planner,
960
+ interpreter: deps.interpreter,
961
+ activation: deps.activation,
962
+ reviewer: deps.reviewer,
963
+ errorStrategy: deps.errorStrategy,
964
+ finalizer: deps.finalizer,
965
+ maxRoundTrips: deps.maxRoundTrips,
966
+ },
967
+ oracleName,
968
+ };
969
+ log({ event: 'dag_coordinator_configured', config: coordCfg });
970
+ }
971
+ else {
972
+ // ── Linear / flat coordinator path ────────────────────────────────────
973
+ // Fail loud if config mixes fields with non-linear settings.
974
+ assertCoordinatorConfigShape(rawCoordCfg);
975
+ // Linear mode: route plannerLlm through the normalized map chain.
976
+ // Priority: map[name] → 'helper'/'planner' alias (helperLlm) →
977
+ // pipelineFallback → mainLlm.
978
+ const linearPlannerName = coordCfg.plannerLlm;
979
+ // Role-resolution order:
980
+ // 1. explicit map[name] → build from that entry
981
+ // 2. name === 'helper' | 'planner' → reuse prebuilt helperLlm
982
+ // 3. unknown name → resolveLlmConfig fallback chain (map.main → pipelineFallback)
983
+ // 4. no name → mainLlm
984
+ const linearPlannerCfgStrict = resolveLlmConfigStrict(llmMap, linearPlannerName);
985
+ const plannerLlm = linearPlannerCfgStrict
986
+ ? await makeLlm({
987
+ provider: linearPlannerCfgStrict.provider ?? 'deepseek',
988
+ apiKey: linearPlannerCfgStrict.apiKey,
989
+ baseURL: linearPlannerCfgStrict.url,
990
+ model: linearPlannerCfgStrict.model,
991
+ }, Number(linearPlannerCfgStrict.temperature ?? mainTemp))
992
+ : linearPlannerName === 'helper' || linearPlannerName === 'planner'
993
+ ? (helperLlm ?? mainLlm)
994
+ : linearPlannerName
995
+ ? // Unknown name, not an alias — try pipelineFallback last.
996
+ await (async () => {
997
+ const fb = resolveLlmConfig(llmMap, linearPlannerName, pipelineFallback);
998
+ return fb
999
+ ? makeLlm({
1000
+ provider: fb.provider ?? 'deepseek',
1001
+ apiKey: fb.apiKey,
1002
+ baseURL: fb.url,
1003
+ model: fb.model,
1004
+ }, Number(fb.temperature ?? mainTemp))
1005
+ : mainLlm;
1006
+ })()
1007
+ : mainLlm;
1008
+ const planningKind = coordCfg.planning ?? 'one-shot';
1009
+ const dispatchKind = resolveCoordinatorDispatchKind(coordCfg.dispatch);
1010
+ // Build the same kind of context builder SmartAgentBuilder uses, so
1011
+ // YAML-configured deployments get the same default behavior. Uses the
1012
+ // embedder resolved above — DI-injected (this.cfg.embedder) when present,
1013
+ // otherwise constructed from `rag.embedder` (#137). Stays undefined for
1014
+ // bare in-memory BM25 stores (no embedder), in which case toolSource is
1015
+ // skipped and constrained subagents fall back to empty context.
1016
+ const mainEmbedder = resolvedEmbedder;
1017
+ const toolSource = mainEmbedder && toolsRag
1018
+ ? async (text, k, signal) => {
1019
+ const embedding = new QueryEmbedding(text, mainEmbedder, {
1020
+ signal,
1021
+ });
1022
+ const r = await toolsRag.query(embedding, k, { signal });
1023
+ return r.ok ? r.value : [];
1024
+ }
1025
+ : undefined;
1026
+ const contextBuilder = new DefaultSubAgentContextBuilder({
1027
+ toolSource,
1028
+ });
1029
+ builder = builder.withCoordinator({
1030
+ planning: resolveCoordinatorPlanning(planningKind, plannerLlm),
1031
+ dispatch: resolveCoordinatorDispatch(dispatchKind, plannerLlm, contextBuilder),
1032
+ // Default to 'explicit' — the presence of a `coordinator:` block in
1033
+ // YAML is itself the opt-in signal. Users that want the auto-fallback
1034
+ // semantics (no subagents and no skill steps → tool-loop) must set
1035
+ // `activation: auto` explicitly.
1036
+ activation: resolveCoordinatorActivation(coordCfg.activation ?? 'explicit'),
1037
+ plannerLlm,
1038
+ maxSteps: coordCfg.maxSteps,
1039
+ maxRetriesPerStep: coordCfg.maxRetriesPerStep,
1040
+ failPolicy: coordCfg.failPolicy,
1041
+ });
1042
+ log({ event: 'coordinator_configured', config: coordCfg });
1043
+ }
330
1044
  }
331
1045
  const agentHandle = await builder.build();
332
1046
  const { agent: smartAgent, chat, streamChat, close: closeAgent, circuitBreakers, ragStores, modelProvider, } = agentHandle;
1047
+ const { ragRegistry: globalRagRegistry, mcpClients: globalMcpClients } = agentHandle;
333
1048
  // ---- API adapter map (built-in → config DI; DI wins) --------------------
334
1049
  const { OpenAiApiAdapter, AnthropicApiAdapter } = await import('@mcp-abap-adt/llm-agent');
335
1050
  const adapterMap = new Map();
@@ -345,6 +1060,45 @@ export class SmartServer {
345
1060
  }
346
1061
  }
347
1062
  const closeFns = [closeAgent];
1063
+ // Stepper-owned MCP clients (connected from YAML mcp: block when no
1064
+ // DI/plugin clients existed). Dispose on server shutdown.
1065
+ // TODO: IMcpClient does not currently expose a close() method; add
1066
+ // `for (const c of this._stepperMcpClients) await c.close?.();`
1067
+ // once the interface gains one.
1068
+ if (this._stepperMcpClients && this._stepperMcpClients.length > 0) {
1069
+ closeFns.push(async () => {
1070
+ this._stepperMcpClients = undefined;
1071
+ });
1072
+ }
1073
+ // ---- Per-session lifecycle (cookie identity + graph factory + registry) ----
1074
+ const sessionCfg = this.cfg.session ?? {};
1075
+ const idleTtlMs = sessionCfg.idleTtlMs ?? 7_200_000;
1076
+ const lifecycle = buildSessionLifecycle({
1077
+ idleTtlMs,
1078
+ maxSessions: sessionCfg.maxSessions ?? 1000,
1079
+ cookieName: sessionCfg.cookieName ?? 'sid',
1080
+ mcpClients: globalMcpClients,
1081
+ toolsRag,
1082
+ ragRegistry: globalRagRegistry,
1083
+ buildAgent: (parts) => this.buildSessionAgent(parts),
1084
+ logger: fileLogger,
1085
+ });
1086
+ this._lifecycle = lifecycle;
1087
+ const sweepMs = Math.min(idleTtlMs, 60_000);
1088
+ const sweep = setInterval(() => {
1089
+ void lifecycle.evictIdle();
1090
+ }, sweepMs);
1091
+ sweep.unref?.();
1092
+ closeFns.push(async () => {
1093
+ clearInterval(sweep);
1094
+ await lifecycle.disposeAll();
1095
+ // Fix #21: per-session graphs may reference worker MCP clients, so
1096
+ // dispose them FIRST (above), THEN drain per-worker handle.close so the
1097
+ // worker-owned MCP clients themselves disconnect. Ordering matters —
1098
+ // closing MCP clients while a session graph is mid-use would cut its
1099
+ // request short.
1100
+ await drainWorkerCache(this._workerLlmCache);
1101
+ });
348
1102
  const startTime = Date.now();
349
1103
  const healthChecker = new HealthChecker({
350
1104
  agent: smartAgent,
@@ -383,7 +1137,59 @@ export class SmartServer {
383
1137
  agentUpdate.classificationEnabled = update.classificationEnabled;
384
1138
  if (Object.keys(agentUpdate).length > 0) {
385
1139
  smartAgent.applyConfigUpdate(agentUpdate);
1140
+ // Mirror onto `this.cfg.agent` so freshly-built session graphs
1141
+ // (which read `this.cfg.agent` in `buildSessionAgent`) observe the
1142
+ // update. Deep-merge to preserve untouched startup fields.
1143
+ // Note: `agentUpdate` includes flat fields ONLY whitelisted by
1144
+ // `AGENT_CONFIG_FIELDS` plus the two prompt fields, which we route
1145
+ // into `this.cfg.prompts` separately below.
1146
+ const agentPatch = {};
1147
+ for (const k of Object.keys(agentUpdate)) {
1148
+ if (k !== 'ragTranslatePrompt' && k !== 'historySummaryPrompt') {
1149
+ agentPatch[k] = agentUpdate[k];
1150
+ }
1151
+ }
1152
+ if (Object.keys(agentPatch).length > 0) {
1153
+ const mergedAgent = {
1154
+ ...(this.cfg.agent ??
1155
+ {}),
1156
+ ...agentPatch,
1157
+ };
1158
+ this.cfg.agent =
1159
+ mergedAgent;
1160
+ }
1161
+ if (update.prompts?.ragTranslate !== undefined ||
1162
+ update.prompts?.historySummary !== undefined) {
1163
+ const mergedPrompts = {
1164
+ ...(this.cfg.prompts ??
1165
+ {}),
1166
+ };
1167
+ if (update.prompts?.ragTranslate !== undefined) {
1168
+ mergedPrompts.ragTranslate = update.prompts.ragTranslate;
1169
+ }
1170
+ if (update.prompts?.historySummary !== undefined) {
1171
+ mergedPrompts.historySummary = update.prompts.historySummary;
1172
+ }
1173
+ this.cfg.prompts =
1174
+ mergedPrompts;
1175
+ }
386
1176
  }
1177
+ // Per-session graphs (built by SessionGraphFactory) captured the OLD
1178
+ // config and the OLD cached worker LLM set. Without invalidation,
1179
+ // existing sessions keep the stale SmartAgent and a fresh acquire on a
1180
+ // cookie-known sessionId still returns it. Clear the worker cache so
1181
+ // the next build reads from the just-applied config, then drop every
1182
+ // session graph. Failures are non-fatal — log and continue.
1183
+ // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing
1184
+ // the cache. Hot-reload runs from a synchronous emitter callback, so
1185
+ // fire-and-forget here — same async-tolerance as the invalidateAll
1186
+ // call below.
1187
+ drainWorkerCache(this._workerLlmCache).catch((err) => {
1188
+ log({ event: 'config_reload_drain_error', error: String(err) });
1189
+ });
1190
+ this._lifecycle?.invalidateAll().catch((err) => {
1191
+ log({ event: 'config_reload_invalidate_error', error: String(err) });
1192
+ });
387
1193
  // Apply RAG weight updates
388
1194
  if (update.vectorWeight !== undefined ||
389
1195
  update.keywordWeight !== undefined) {
@@ -422,9 +1228,17 @@ export class SmartServer {
422
1228
  resolve({
423
1229
  port: actualPort,
424
1230
  close: async () => {
1231
+ // 1. Stop accepting new connections AND wait for in-flight HTTP
1232
+ // requests to drain. Until server.close() resolves, requests
1233
+ // accepted before shutdown may still be running and pinning
1234
+ // per-session graphs — disposing those graphs first would
1235
+ // violate the active-request pinning guarantee.
1236
+ await new Promise((res, rej) => server.close((e) => (e ? rej(e) : res())));
1237
+ // 2. Now run lifecycle cleanup: sweep timer, lifecycle.disposeAll,
1238
+ // config watcher stop, agent close. By this point no HTTP
1239
+ // request is in flight, so disposing session graphs is safe.
425
1240
  for (const fn of closeFns)
426
1241
  await fn();
427
- await new Promise((res, rej) => server.close((e) => (e ? rej(e) : res())));
428
1242
  },
429
1243
  requestLogger,
430
1244
  });
@@ -447,37 +1261,93 @@ export class SmartServer {
447
1261
  * rejected at config-parse time, so `subCfg.subAgentConfigs` is
448
1262
  * always undefined here.
449
1263
  */
450
- async buildSubAgent(name, subCfg, parentLogger, embedderFactories) {
451
- if (!subCfg.llm?.apiKey && !subCfg.pipeline?.llm?.main) {
1264
+ async buildSubAgent(name, subCfg, parentLogger, embedderFactories, injected) {
1265
+ // Normalize subagent llm: either flat { provider, apiKey, ... } or a map
1266
+ // { main: {...}, planner: {...} }. normalizeLlmConfig wraps flat shape as
1267
+ // { main: flat } so downstream code always reads from .main.
1268
+ const subLlmMap = normalizeLlmConfig(subCfg.llm);
1269
+ const subLlmMain = subLlmMap?.main;
1270
+ if (!subLlmMain?.apiKey &&
1271
+ subLlmMain?.provider !== 'sap-ai-sdk' &&
1272
+ subLlmMain?.provider !== 'ollama' &&
1273
+ !subCfg.pipeline?.llm?.main) {
452
1274
  throw new Error(`subagent '${name}': LLM API key is required`);
453
1275
  }
454
1276
  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);
1277
+ // LLM/embedder clients: when the per-session re-wire injected them, use
1278
+ // those cached instances by reference (NEVER reconstruct). Otherwise (the
1279
+ // primary build()), build-once via the cache so the global agent build
1280
+ // also populates it and later per-session re-wires reuse the SAME
1281
+ // instances.
1282
+ // Note: a per-worker embedder slot is carried in WorkerLlmSet and the
1283
+ // injected record for forward-compat with Task A8/A10 per-session wiring.
1284
+ // Today's buildSubAgent does not separately resolve an embedder here —
1285
+ // embedders are carried by the worker's own store via makeRag's
1286
+ // `injectedEmbedder` — so we ignore the embedder field below.
1287
+ // Resolve (build-once or load from cache) the worker's own LLMs +
1288
+ // toolsRag/historyRag/mcpClients. The cache is keyed by worker name; the
1289
+ // primary build() populates it (no `injected` arg), and per-session
1290
+ // re-wires (`injected` set) read from it via the same call below — the
1291
+ // cache hit short-circuits all factories. This keeps the worker's
1292
+ // declared RAG/MCP intact across per-session re-wires (review HIGH #1).
1293
+ const subFlatLlm = subLlmMain;
1294
+ const mainTemp = Number(subPipeline?.llm?.main?.temperature ?? subFlatLlm?.temperature ?? 0.7);
466
1295
  const classifierTemp = Number(subPipeline?.llm?.classifier?.temperature ??
467
- subCfg.llm.classifierTemperature ??
1296
+ subFlatLlm?.classifierTemperature ??
468
1297
  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);
1298
+ const cached = await resolveWorkerLlmSet({
1299
+ name,
1300
+ cache: this._workerLlmCache,
1301
+ // Preserve the existing makeLlm derivation exactly.
1302
+ makeMain: () => subPipeline?.llm?.main
1303
+ ? makeLlm(subPipeline.llm.main, mainTemp)
1304
+ : makeLlm({
1305
+ // ?? 'deepseek' is a TS type-narrowing net only; the config
1306
+ // validator rejects a missing flat-schema provider before
1307
+ // this runs.
1308
+ provider: subFlatLlm?.provider ?? 'deepseek',
1309
+ apiKey: subFlatLlm?.apiKey ?? '',
1310
+ baseURL: subFlatLlm?.url,
1311
+ model: subFlatLlm?.model,
1312
+ }, mainTemp),
1313
+ makeClassifier: () => subPipeline?.llm?.classifier
1314
+ ? makeLlm(subPipeline.llm.classifier, classifierTemp)
1315
+ : subPipeline?.llm?.main
1316
+ ? makeLlm(subPipeline.llm.main, classifierTemp)
1317
+ : makeLlm({
1318
+ provider: subFlatLlm?.provider ?? 'deepseek',
1319
+ apiKey: subFlatLlm?.apiKey ?? '',
1320
+ baseURL: subFlatLlm?.url,
1321
+ model: subFlatLlm?.model,
1322
+ }, classifierTemp),
1323
+ makeHelper: subPipeline?.llm?.helper
1324
+ ? ((h) => () => makeLlm(h, Number(h.temperature ?? 0.1)))(subPipeline.llm.helper)
1325
+ : undefined,
1326
+ // Worker-OWN tools RAG (from subCfg.rag, if declared). Built once;
1327
+ // re-wired per-session by reference — never re-vectorized.
1328
+ makeToolsRag: subCfg.rag
1329
+ ? () => makeRag(subCfg.rag, {
1330
+ injectedEmbedder: subCfg.embedder,
1331
+ extraFactories: embedderFactories,
1332
+ })
1333
+ : undefined,
1334
+ makeHistoryRag: subCfg.rag
1335
+ ? () => makeRag({ ...subCfg.rag }, {
1336
+ injectedEmbedder: subCfg.embedder,
1337
+ extraFactories: embedderFactories,
1338
+ })
1339
+ : undefined,
1340
+ // Worker-OWN MCP clients. DI list (subCfg.mcpClients) wins; otherwise
1341
+ // SmartAgentBuilder's own MCP-connect path handles `subCfg.mcp` — we
1342
+ // don't pre-build those here (connection is the builder's job and is
1343
+ // not safe to invoke twice). The cache stores the DI clients only.
1344
+ makeMcpClients: subCfg.mcpClients && subCfg.mcpClients.length > 0
1345
+ ? async () => subCfg.mcpClients
1346
+ : undefined,
1347
+ });
1348
+ const mainLlm = cached.mainLlm;
1349
+ const classifierLlm = cached.classifierLlm;
1350
+ const helperLlm = cached.helperLlm;
481
1351
  let subBuilder = new SmartAgentBuilder({
482
1352
  mcp: subPipeline?.mcp ?? subCfg.mcp,
483
1353
  agent: subCfg.agent,
@@ -488,27 +1358,209 @@ export class SmartServer {
488
1358
  .withClassifierLlm(classifierLlm)
489
1359
  .withLogger(parentLogger)
490
1360
  .withMode(subCfg.mode ?? 'smart');
491
- if (subPipeline?.llm?.helper) {
492
- const helperLlm = await makeLlm(subPipeline.llm.helper, Number(subPipeline.llm.helper.temperature ?? 0.1));
1361
+ if (helperLlm) {
493
1362
  subBuilder = subBuilder.withHelperLlm(helperLlm);
494
1363
  }
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));
1364
+ // SHARE the parent RAG registry + session logger when injected (per-session
1365
+ // worker re-wire). The per-call scope filter isolates by ctx.sessionId.
1366
+ const sharedReg = resolveSubAgentRagRegistry({
1367
+ parentRagRegistry: injected?.ragRegistry,
1368
+ });
1369
+ if (sharedReg)
1370
+ subBuilder = subBuilder.setRagRegistry(sharedReg);
1371
+ if (injected?.requestLogger) {
1372
+ subBuilder = subBuilder.withRequestLogger(injected.requestLogger);
1373
+ }
1374
+ // Tools/History RAG priority (review HIGH #1):
1375
+ // 1) worker's OWN cached toolsRag (from subCfg.rag) — built once, reused
1376
+ // by reference across per-session re-wires (never re-vectorized);
1377
+ // 2) parent's injected toolsRag (fallback for workers that did not
1378
+ // declare their own store).
1379
+ // History RAG: only when the worker has its own cached instance — the
1380
+ // parent's history RAG is owned by the parent agent and is not shared.
1381
+ if (cached.toolsRag) {
1382
+ subBuilder = subBuilder.setToolsRag(cached.toolsRag);
1383
+ if (cached.historyRag) {
1384
+ subBuilder = subBuilder.setHistoryRag(cached.historyRag);
1385
+ }
1386
+ }
1387
+ else if (injected?.toolsRag) {
1388
+ subBuilder = subBuilder.setToolsRag(injected.toolsRag);
502
1389
  }
503
1390
  if (subCfg.skillManager) {
504
1391
  subBuilder = subBuilder.withSkillManager(subCfg.skillManager);
505
1392
  }
506
- if (subCfg.mcpClients && subCfg.mcpClients.length > 0) {
507
- subBuilder = subBuilder.withMcpClients(subCfg.mcpClients);
1393
+ // MCP clients priority (review HIGH #1):
1394
+ // 1) worker's OWN cached MCP clients (from subCfg.mcpClients DI) — keeps
1395
+ // the worker pointed at its own upstream when the parent has none;
1396
+ // 2) parent's injected GLOBAL MCP clients (fallback) — skips re-connect.
1397
+ // If neither is set, fall through to the builder's own MCP-connect path
1398
+ // (which honours `subCfg.mcp`).
1399
+ if (cached.mcpClients && cached.mcpClients.length > 0) {
1400
+ subBuilder = subBuilder.withMcpClients(cached.mcpClients);
1401
+ }
1402
+ else if (injected?.mcpClients && injected.mcpClients.length > 0) {
1403
+ subBuilder = subBuilder.withMcpClients(injected.mcpClients);
508
1404
  }
509
1405
  const handle = await subBuilder.build();
1406
+ // Backfill the per-worker cache from the BUILT handle (review HIGH #7).
1407
+ // Only runs on the primary build path (no `injected`) so per-session
1408
+ // re-wires never overwrite the cache. See backfillWorkerCacheFromHandle's
1409
+ // doc-comment for the rationale.
1410
+ if (!injected) {
1411
+ const entry = this._workerLlmCache.get(name);
1412
+ if (entry)
1413
+ await backfillWorkerCacheFromHandle(entry, handle);
1414
+ }
1415
+ return handle.agent;
1416
+ }
1417
+ /**
1418
+ * Builds a per-session SmartAgent from injected globals (review HIGH #1 +
1419
+ * MEDIUM #3). Re-wires the SubAgent registry + DAG coordinator deps FRESH per
1420
+ * session, sharing this session's logger + the global ragRegistry/toolsRag/
1421
+ * mcpClients + the CACHED per-worker LLM/embedder (this._workerLlmCache). It
1422
+ * NEVER reuses the primary build()'s global `registry`/DAG-deps and NEVER
1423
+ * constructs new LLM clients.
1424
+ */
1425
+ async buildSessionAgent(parts) {
1426
+ // Guard: globals must already be captured by the primary build() before any
1427
+ // session graph is built (the registry calls this lazily on first acquire).
1428
+ if (!this._mainLlm || !this._classifierLlm || !this._fileLogger) {
1429
+ throw new Error('buildSessionAgent invoked before primary build() captured globals');
1430
+ }
1431
+ let b = new SmartAgentBuilder({
1432
+ agent: this.cfg.agent,
1433
+ prompts: this.cfg.prompts,
1434
+ skipModelValidation: this.cfg.skipModelValidation,
1435
+ })
1436
+ .withMainLlm(this._mainLlm)
1437
+ .withClassifierLlm(this._classifierLlm)
1438
+ .withLogger(this._fileLogger)
1439
+ .withMode(this.cfg.mode ?? 'smart')
1440
+ .withMcpClients(parts.mcpClients) // skips connect + re-vectorize
1441
+ .setRagRegistry(parts.ragRegistry)
1442
+ .withRequestLogger(parts.logger); // per-session token-logger
1443
+ if (this._helperLlm)
1444
+ b = b.withHelperLlm(this._helperLlm);
1445
+ if (parts.toolsRag)
1446
+ b = b.setToolsRag(parts.toolsRag);
1447
+ // FRESH per-session workers — re-wire the registry from the SAME subagent
1448
+ // configs the primary build() used, injecting globals + this session's
1449
+ // logger + the CACHED per-worker LLM/embedder. No LLM reconstruction.
1450
+ if (this.cfg.subAgentConfigs && this.cfg.subAgentConfigs.length > 0) {
1451
+ const registry = new Map();
1452
+ for (const sub of this.cfg.subAgentConfigs) {
1453
+ // Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
1454
+ // clears `_workerLlmCache`, the next buildSessionAgent used to throw
1455
+ // "worker LLM set not cached" because the cache was assumed
1456
+ // pre-populated by the primary build(). buildSubAgent itself routes
1457
+ // through `resolveWorkerLlmSet` which is build-on-miss, so calling
1458
+ // it without an `injected` arg rebuilds the cache entry. We then
1459
+ // re-read the entry to honour the per-worker slot priority below.
1460
+ if (!this._workerLlmCache.has(sub.name)) {
1461
+ await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
1462
+ }
1463
+ const cached = this._workerLlmCache.get(sub.name);
1464
+ if (!cached) {
1465
+ // Defence in depth — should be impossible after the lazy build
1466
+ // above unless buildSubAgent's contract changes.
1467
+ throw new Error(`worker LLM set not cached for '${sub.name}'`);
1468
+ }
1469
+ // Per-worker injected slot priority (review HIGH #7):
1470
+ // worker-cached (from the primary build, includes backfilled
1471
+ // subCfg.mcp / subCfg.rag results) → parent's session-scoped
1472
+ // fallback. Encoded HERE so buildSubAgent does not need to know
1473
+ // the difference; it just consumes injected.mcpClients/toolsRag.
1474
+ const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
1475
+ ? cached.mcpClients
1476
+ : parts.mcpClients;
1477
+ const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
1478
+ const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
1479
+ ragRegistry: parts.ragRegistry,
1480
+ toolsRag: injectedToolsRag,
1481
+ mcpClients: injectedMcpClients,
1482
+ requestLogger: parts.logger,
1483
+ mainLlm: cached.mainLlm,
1484
+ classifierLlm: cached.classifierLlm,
1485
+ helperLlm: cached.helperLlm,
1486
+ embedder: cached.embedder,
1487
+ });
1488
+ registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
1489
+ description: sub.description,
1490
+ }));
1491
+ }
1492
+ b = b.withSubAgents(registry);
1493
+ if (this._stepperCoordinatorHandler) {
1494
+ // Stepper coordinator is stateless — reuse the same handler instance
1495
+ // across sessions (session context arrives via ctx.sessionId).
1496
+ b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
1497
+ }
1498
+ else if (this._dagCoordinatorTemplate) {
1499
+ const tpl = this._dagCoordinatorTemplate;
1500
+ const workers = new Map([...registry].filter(([name]) => name !== tpl.oracleName));
1501
+ const raw = tpl.oracleName ? registry.get(tpl.oracleName) : undefined;
1502
+ b = b.withDagCoordinator({
1503
+ ...tpl.deps,
1504
+ workers,
1505
+ stateOracle: raw ? new SubAgentStateOracle(raw) : undefined,
1506
+ });
1507
+ }
1508
+ }
1509
+ else if (this._stepperCoordinatorHandler) {
1510
+ // No sub-agent configs but stepper coordinator configured — wire it directly.
1511
+ // (Stepper coordinator is stateless and works without sub-agents.)
1512
+ b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
1513
+ }
1514
+ const handle = await b.build();
510
1515
  return handle.agent;
511
1516
  }
1517
+ /**
1518
+ * Resolve identity (mint cookie when needed), acquire the per-session graph,
1519
+ * run `fn` pinned, and release in `finally`. Order in `finally`:
1520
+ * 1. drop the per-traceId logger delta (server-owned free, review HIGH #2)
1521
+ * 2. release the refcount pin
1522
+ */
1523
+ async _withSession(req, res, fn) {
1524
+ const lifecycle = this._lifecycle;
1525
+ if (!lifecycle) {
1526
+ throw new Error('SmartServer lifecycle not initialized');
1527
+ }
1528
+ const traceId = randomUUID();
1529
+ const isHttps = req.socket.encrypted === true ||
1530
+ req.headers['x-forwarded-proto'] === 'https';
1531
+ const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1532
+ const sessionId = resolved.identity.sessionId;
1533
+ if (resolved.minted && resolved.setCookie) {
1534
+ res.setHeader('Set-Cookie', resolved.setCookie);
1535
+ }
1536
+ const graph = await lifecycle.acquire(sessionId);
1537
+ // Register/touch the session in the meta store so /v1/sessions, resume and
1538
+ // delete reflect real chat/stream traffic (review Finding 3). Best-effort:
1539
+ // a meta-store hiccup must never break the actual request.
1540
+ try {
1541
+ await recordSessionStart(this._sessionMetaStore, sessionId, new Date().toISOString());
1542
+ }
1543
+ catch {
1544
+ // swallow — session metadata is non-critical to serving the request
1545
+ }
1546
+ try {
1547
+ await fn(graph, sessionId, traceId);
1548
+ }
1549
+ finally {
1550
+ try {
1551
+ await recordSessionEnd(this._sessionMetaStore, sessionId, new Date().toISOString());
1552
+ }
1553
+ catch {
1554
+ // swallow — see above
1555
+ }
1556
+ graph.logger.dropRequest(traceId);
1557
+ // Pass the graph instance — `invalidateAll()` may have detached this
1558
+ // graph into the draining map while the request was in flight; we must
1559
+ // release THIS specific instance, not whatever currently lives under
1560
+ // `sessionId` in the registry.
1561
+ lifecycle.release(sessionId, graph);
1562
+ }
1563
+ }
512
1564
  async _handle(req, res, requestLogger, smartAgent, chat, streamChat, log, healthChecker, modelProvider, adapterMap) {
513
1565
  const rawUrl = req.url ?? '/';
514
1566
  const urlPath = rawUrl.split('?')[0].replace(/\/$/, '') || '/';
@@ -581,10 +1633,109 @@ export class SmartServer {
581
1633
  return;
582
1634
  }
583
1635
  if (req.method === 'GET' && urlPath === '/v1/usage') {
1636
+ const lifecycle = this._lifecycle;
1637
+ if (!lifecycle) {
1638
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1639
+ res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1640
+ return;
1641
+ }
1642
+ const isHttps = req.socket.encrypted === true ||
1643
+ req.headers['x-forwarded-proto'] === 'https';
1644
+ const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1645
+ if (resolved.minted && resolved.setCookie) {
1646
+ res.setHeader('Set-Cookie', resolved.setCookie);
1647
+ }
1648
+ const sessionId = resolved.identity.sessionId;
1649
+ const graph = await lifecycle.acquire(sessionId);
1650
+ try {
1651
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1652
+ res.end(JSON.stringify(graph.logger.getSummary()));
1653
+ }
1654
+ finally {
1655
+ lifecycle.release(sessionId, graph);
1656
+ }
1657
+ return;
1658
+ }
1659
+ // GET /v1/sessions — list sessions for the current identity
1660
+ if (req.method === 'GET' && urlPath === '/v1/sessions') {
1661
+ const lifecycle = this._lifecycle;
1662
+ if (!lifecycle) {
1663
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1664
+ res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1665
+ return;
1666
+ }
1667
+ const isHttps = req.socket.encrypted === true ||
1668
+ req.headers['x-forwarded-proto'] === 'https';
1669
+ const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1670
+ if (resolved.minted && resolved.setCookie) {
1671
+ res.setHeader('Set-Cookie', resolved.setCookie);
1672
+ }
1673
+ const identity = resolved.identity.sessionId;
1674
+ const body = await handleListSessions(this._sessionMetaStore, identity);
584
1675
  res.writeHead(200, { 'Content-Type': 'application/json' });
585
- res.end(JSON.stringify(requestLogger.getSummary()));
1676
+ res.end(JSON.stringify(body));
586
1677
  return;
587
1678
  }
1679
+ // POST /v1/sessions/:id/resume — resume a session
1680
+ {
1681
+ const resumeMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)\/resume$/);
1682
+ if (req.method === 'POST' && resumeMatch) {
1683
+ const sessionId = resumeMatch[1];
1684
+ const lifecycle = this._lifecycle;
1685
+ if (!lifecycle) {
1686
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1687
+ res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1688
+ return;
1689
+ }
1690
+ const isHttps = req.socket.encrypted === true ||
1691
+ req.headers['x-forwarded-proto'] === 'https';
1692
+ const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1693
+ if (resolved.minted && resolved.setCookie) {
1694
+ res.setHeader('Set-Cookie', resolved.setCookie);
1695
+ }
1696
+ const identity = resolved.identity.sessionId;
1697
+ const body = await handleResumeSession(this._sessionMetaStore, identity, sessionId);
1698
+ const status = body.ok ? 200 : 404;
1699
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1700
+ res.end(JSON.stringify(body));
1701
+ return;
1702
+ }
1703
+ }
1704
+ // DELETE /v1/sessions/:id — delete a session
1705
+ {
1706
+ const deleteMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)$/);
1707
+ if (req.method === 'DELETE' && deleteMatch) {
1708
+ const sessionId = deleteMatch[1];
1709
+ const lifecycle = this._lifecycle;
1710
+ if (!lifecycle) {
1711
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1712
+ res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1713
+ return;
1714
+ }
1715
+ const isHttps = req.socket.encrypted === true ||
1716
+ req.headers['x-forwarded-proto'] === 'https';
1717
+ const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1718
+ if (resolved.minted && resolved.setCookie) {
1719
+ res.setHeader('Set-Cookie', resolved.setCookie);
1720
+ }
1721
+ const identity = resolved.identity.sessionId;
1722
+ const evictFn = async (sid) => {
1723
+ // (a) Evict/dispose this session's graph from the registry.
1724
+ await lifecycle.registry.evictOne(sid);
1725
+ // (b) Evict the session's knowledge from the shared backend. This
1726
+ // clears the long-lived in-memory backend AND removes the JSONL files
1727
+ // (JsonlKnowledgeBackend.deleteSession), so a same-id re-entry never
1728
+ // rehydrates stale entries — matching the README "evicts its
1729
+ // knowledge-RAG entries" contract.
1730
+ await this._stepperKnowledgeBackend?.deleteSession(sid);
1731
+ };
1732
+ const body = await handleDeleteSession(this._sessionMetaStore, identity, sessionId, evictFn);
1733
+ const status = body.ok ? 200 : 404;
1734
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1735
+ res.end(JSON.stringify(body));
1736
+ return;
1737
+ }
1738
+ }
588
1739
  // /v1/config or /config
589
1740
  if (urlPath === '/v1/config' || urlPath === '/config') {
590
1741
  if (req.method === 'GET') {
@@ -622,18 +1773,22 @@ export class SmartServer {
622
1773
  res.end(jsonError('Anthropic adapter not registered', 'not_found'));
623
1774
  return;
624
1775
  }
625
- await this._handleAdapterRequest(req, res, smartAgent, anthropicAdapter);
1776
+ await this._withSession(req, res, async (graph, sessionId, traceId) => {
1777
+ await this._handleAdapterRequest(req, res, graph.agent ?? smartAgent, anthropicAdapter, { sessionId, traceId, graph });
1778
+ });
626
1779
  return;
627
1780
  }
628
1781
  if (req.method === 'POST' &&
629
1782
  (urlPath === '/v1/chat/completions' || urlPath === '/chat/completions')) {
630
- await this._handleChat(req, res, requestLogger, smartAgent, chat, streamChat, log, modelProvider);
1783
+ await this._withSession(req, res, async (graph, sessionId, traceId) => {
1784
+ await this._handleChat(req, res, requestLogger, graph.agent ?? smartAgent, chat, streamChat, log, modelProvider, { sessionId, traceId, graph });
1785
+ });
631
1786
  return;
632
1787
  }
633
1788
  res.writeHead(404, { 'Content-Type': 'application/json' });
634
1789
  res.end(jsonError(`Cannot ${req.method} ${urlPath}`, 'invalid_request_error'));
635
1790
  }
636
- async _handleAdapterRequest(req, res, agent, adapter) {
1791
+ async _handleAdapterRequest(req, res, agent, adapter, session) {
637
1792
  const raw = await readBody(req);
638
1793
  let body;
639
1794
  try {
@@ -656,13 +1811,22 @@ export class SmartServer {
656
1811
  }
657
1812
  throw err;
658
1813
  }
1814
+ const augmentedOptions = session
1815
+ ? {
1816
+ ...normalized.options,
1817
+ sessionId: session.sessionId,
1818
+ trace: { traceId: session.traceId },
1819
+ toolAvailability: session.graph.toolAvailability,
1820
+ pendingToolResults: session.graph.pendingToolResults,
1821
+ }
1822
+ : normalized.options;
659
1823
  if (normalized.stream) {
660
1824
  res.writeHead(200, {
661
1825
  'Content-Type': 'text/event-stream',
662
1826
  'Cache-Control': 'no-cache',
663
1827
  Connection: 'keep-alive',
664
1828
  });
665
- for await (const event of adapter.transformStream(agent.streamProcess(normalized.messages, normalized.options), normalized.context)) {
1829
+ for await (const event of adapter.transformStream(agent.streamProcess(normalized.messages, augmentedOptions), normalized.context)) {
666
1830
  const eventLine = event.event ? `event: ${event.event}\n` : '';
667
1831
  res.write(`${eventLine}data: ${event.data}\n\n`);
668
1832
  }
@@ -670,7 +1834,7 @@ export class SmartServer {
670
1834
  return;
671
1835
  }
672
1836
  // Non-streaming
673
- const result = await agent.process(normalized.messages, normalized.options);
1837
+ const result = await agent.process(normalized.messages, augmentedOptions);
674
1838
  res.setHeader('Content-Type', 'application/json');
675
1839
  if (!result.ok) {
676
1840
  res.writeHead(500);
@@ -685,7 +1849,7 @@ export class SmartServer {
685
1849
  res.writeHead(200);
686
1850
  res.end(JSON.stringify(adapter.formatResult(result.value, normalized.context)));
687
1851
  }
688
- async _handleChat(req, res, _requestLogger, smartAgent, _chat, _streamChat, log, modelProvider) {
1852
+ async _handleChat(req, res, _requestLogger, smartAgent, _chat, _streamChat, log, modelProvider, session) {
689
1853
  const rawBody = await readBody(req);
690
1854
  let parsed;
691
1855
  try {
@@ -725,8 +1889,13 @@ export class SmartServer {
725
1889
  res.end(jsonError('at least one message with role "user" is required', 'invalid_request_error'));
726
1890
  return;
727
1891
  }
728
- const traceId = randomUUID();
729
- const sessionId = req.headers['x-session-id'] || 'default';
1892
+ // Prefer the session injected by `_withSession` (cookie identity); fall
1893
+ // back to the legacy x-session-id header / 'default' bucket only when no
1894
+ // session was wired (defensive — production routes always inject one).
1895
+ const traceId = session?.traceId ?? randomUUID();
1896
+ const sessionId = session?.sessionId ??
1897
+ req.headers['x-session-id'] ??
1898
+ 'default';
730
1899
  const sessionLogger = new SessionLogger(this.cfg.logDir || null, sessionId, traceId);
731
1900
  const toolsValidationMode = this.cfg.agent?.externalToolsValidationMode ?? 'permissive';
732
1901
  const externalToolsValidation = normalizeAndValidateExternalTools(body.tools);
@@ -761,6 +1930,12 @@ export class SmartServer {
761
1930
  trace: { traceId },
762
1931
  sessionLogger,
763
1932
  model: body.model,
1933
+ ...(session
1934
+ ? {
1935
+ toolAvailability: session.graph.toolAvailability,
1936
+ pendingToolResults: session.graph.pendingToolResults,
1937
+ }
1938
+ : {}),
764
1939
  ...(body.temperature !== undefined
765
1940
  ? { temperature: body.temperature }
766
1941
  : {}),
@@ -1085,9 +2260,48 @@ export class SmartServer {
1085
2260
  // --- All validation passed — apply mutations ---
1086
2261
  if (resolvedModels) {
1087
2262
  smartAgent.reconfigure(resolvedModels);
2263
+ // Mirror onto the hoisted globals consumed by `buildSessionAgent` so
2264
+ // freshly-built session graphs pick up the new LLMs by reference
2265
+ // (otherwise `this._mainLlm` etc. would keep pointing at the originals
2266
+ // captured during `start()`).
2267
+ if (resolvedModels.mainLlm)
2268
+ this._mainLlm = resolvedModels.mainLlm;
2269
+ if (resolvedModels.classifierLlm)
2270
+ this._classifierLlm = resolvedModels.classifierLlm;
2271
+ if (resolvedModels.helperLlm)
2272
+ this._helperLlm = resolvedModels.helperLlm;
1088
2273
  }
1089
2274
  if (body.agent) {
1090
- smartAgent.applyConfigUpdate(body.agent);
2275
+ const patch = body.agent;
2276
+ smartAgent.applyConfigUpdate(patch);
2277
+ // Mirror onto `this.cfg.agent` so freshly-built session graphs (which
2278
+ // read `this.cfg.agent` in `buildSessionAgent`) observe the update.
2279
+ // Deep-merge to preserve untouched startup fields; replacing the whole
2280
+ // `agent` block would drop YAML defaults the validator already applied.
2281
+ const merged = {
2282
+ ...(this.cfg.agent ?? {}),
2283
+ ...patch,
2284
+ };
2285
+ this.cfg.agent = merged;
2286
+ }
2287
+ // Invalidate per-session SmartAgents + the worker-LLM cache so the next
2288
+ // request mints a session graph that observes the just-applied config.
2289
+ // Without this, chat routes dispatch to `graph.agent` (the per-session
2290
+ // SmartAgent) which was built with the OLD config, and the PUT is a
2291
+ // no-op from the consumer's perspective. Failures are non-fatal so the
2292
+ // 200 response isn't blocked by a dispose hiccup.
2293
+ if (resolvedModels || body.agent) {
2294
+ // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing the
2295
+ // cache so MCP clients owned by the discarded handles disconnect.
2296
+ await drainWorkerCache(this._workerLlmCache);
2297
+ try {
2298
+ await this._lifecycle?.invalidateAll();
2299
+ }
2300
+ catch {
2301
+ // Swallow: cleanup errors must not turn a successful config update
2302
+ // into a 500. The next request will still get a fresh build because
2303
+ // `_workerLlmCache` is already cleared and dispose is idempotent.
2304
+ }
1091
2305
  }
1092
2306
  // --- Return updated config ---
1093
2307
  const models = smartAgent.getActiveConfig();