@mcp-abap-adt/llm-agent-server-libs 18.1.2 → 19.1.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 (101) hide show
  1. package/dist/generated/version.d.ts +1 -1
  2. package/dist/generated/version.js +1 -1
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/legacy/dag.d.ts +4 -0
  8. package/dist/legacy/dag.d.ts.map +1 -0
  9. package/dist/legacy/dag.js +4 -0
  10. package/dist/legacy/dag.js.map +1 -0
  11. package/dist/legacy/flat.d.ts +2 -0
  12. package/dist/legacy/flat.d.ts.map +1 -0
  13. package/dist/legacy/flat.js +3 -0
  14. package/dist/legacy/flat.js.map +1 -0
  15. package/dist/legacy/linear.d.ts +3 -0
  16. package/dist/legacy/linear.d.ts.map +1 -0
  17. package/dist/legacy/linear.js +3 -0
  18. package/dist/legacy/linear.js.map +1 -0
  19. package/dist/legacy/stepper.d.ts +5 -0
  20. package/dist/legacy/stepper.d.ts.map +1 -0
  21. package/dist/legacy/stepper.js +5 -0
  22. package/dist/legacy/stepper.js.map +1 -0
  23. package/dist/pipelines/controller.d.ts +19 -0
  24. package/dist/pipelines/controller.d.ts.map +1 -0
  25. package/dist/pipelines/controller.js +100 -0
  26. package/dist/pipelines/controller.js.map +1 -0
  27. package/dist/pipelines/dag.d.ts +23 -0
  28. package/dist/pipelines/dag.d.ts.map +1 -0
  29. package/dist/pipelines/dag.js +46 -0
  30. package/dist/pipelines/dag.js.map +1 -0
  31. package/dist/pipelines/flat.d.ts +14 -0
  32. package/dist/pipelines/flat.d.ts.map +1 -0
  33. package/dist/pipelines/flat.js +18 -0
  34. package/dist/pipelines/flat.js.map +1 -0
  35. package/dist/pipelines/linear.d.ts +17 -0
  36. package/dist/pipelines/linear.d.ts.map +1 -0
  37. package/dist/pipelines/linear.js +26 -0
  38. package/dist/pipelines/linear.js.map +1 -0
  39. package/dist/pipelines/parsers.d.ts +5 -0
  40. package/dist/pipelines/parsers.d.ts.map +1 -0
  41. package/dist/pipelines/parsers.js +20 -0
  42. package/dist/pipelines/parsers.js.map +1 -0
  43. package/dist/pipelines/server-context.d.ts +53 -0
  44. package/dist/pipelines/server-context.d.ts.map +1 -0
  45. package/dist/pipelines/server-context.js +9 -0
  46. package/dist/pipelines/server-context.js.map +1 -0
  47. package/dist/pipelines/stepper.d.ts +23 -0
  48. package/dist/pipelines/stepper.d.ts.map +1 -0
  49. package/dist/pipelines/stepper.js +60 -0
  50. package/dist/pipelines/stepper.js.map +1 -0
  51. package/dist/smart-agent/config.d.ts +13 -4
  52. package/dist/smart-agent/config.d.ts.map +1 -1
  53. package/dist/smart-agent/config.js +137 -228
  54. package/dist/smart-agent/config.js.map +1 -1
  55. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +99 -0
  56. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -0
  57. package/dist/smart-agent/controller/controller-coordinator-handler.js +627 -0
  58. package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -0
  59. package/dist/smart-agent/controller/memorizer.d.ts +6 -0
  60. package/dist/smart-agent/controller/memorizer.d.ts.map +1 -0
  61. package/dist/smart-agent/controller/memorizer.js +5 -0
  62. package/dist/smart-agent/controller/memorizer.js.map +1 -0
  63. package/dist/smart-agent/controller/need-resolver.d.ts +3 -0
  64. package/dist/smart-agent/controller/need-resolver.d.ts.map +1 -0
  65. package/dist/smart-agent/controller/need-resolver.js +4 -0
  66. package/dist/smart-agent/controller/need-resolver.js.map +1 -0
  67. package/dist/smart-agent/controller/planner.d.ts +34 -0
  68. package/dist/smart-agent/controller/planner.d.ts.map +1 -0
  69. package/dist/smart-agent/controller/planner.js +247 -0
  70. package/dist/smart-agent/controller/planner.js.map +1 -0
  71. package/dist/smart-agent/controller/prompts.d.ts +17 -0
  72. package/dist/smart-agent/controller/prompts.d.ts.map +1 -0
  73. package/dist/smart-agent/controller/prompts.js +20 -0
  74. package/dist/smart-agent/controller/prompts.js.map +1 -0
  75. package/dist/smart-agent/controller/session-bundle.d.ts +15 -0
  76. package/dist/smart-agent/controller/session-bundle.d.ts.map +1 -0
  77. package/dist/smart-agent/controller/session-bundle.js +53 -0
  78. package/dist/smart-agent/controller/session-bundle.js.map +1 -0
  79. package/dist/smart-agent/controller/subagent-client.d.ts +7 -0
  80. package/dist/smart-agent/controller/subagent-client.d.ts.map +1 -0
  81. package/dist/smart-agent/controller/subagent-client.js +18 -0
  82. package/dist/smart-agent/controller/subagent-client.js.map +1 -0
  83. package/dist/smart-agent/controller/target-state.d.ts +28 -0
  84. package/dist/smart-agent/controller/target-state.d.ts.map +1 -0
  85. package/dist/smart-agent/controller/target-state.js +59 -0
  86. package/dist/smart-agent/controller/target-state.js.map +1 -0
  87. package/dist/smart-agent/controller/types.d.ts +121 -0
  88. package/dist/smart-agent/controller/types.d.ts.map +1 -0
  89. package/dist/smart-agent/controller/types.js +2 -0
  90. package/dist/smart-agent/controller/types.js.map +1 -0
  91. package/dist/smart-agent/resolve-agent-embedder.d.ts.map +1 -1
  92. package/dist/smart-agent/resolve-agent-embedder.js +8 -2
  93. package/dist/smart-agent/resolve-agent-embedder.js.map +1 -1
  94. package/dist/smart-agent/smart-server.d.ts +188 -31
  95. package/dist/smart-agent/smart-server.d.ts.map +1 -1
  96. package/dist/smart-agent/smart-server.js +809 -637
  97. package/dist/smart-agent/smart-server.js.map +1 -1
  98. package/dist/smart-agent/stepper-coordinator-handler.d.ts.map +1 -1
  99. package/dist/smart-agent/stepper-coordinator-handler.js +25 -11
  100. package/dist/smart-agent/stepper-coordinator-handler.js.map +1 -1
  101. package/package.json +43 -7
@@ -3,12 +3,15 @@
3
3
  */
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import http from 'node:http';
6
- import { AdapterValidationError, artifactIdentityKey, normalizeAndValidateExternalTools, QueryEmbedding, toToolCallDelta, } from '@mcp-abap-adt/llm-agent';
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';
6
+ import { createRequire } from 'node:module';
7
+ import { resolve as pathResolve } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { AdapterValidationError, buildExternalResults, normalizeAndValidateExternalTools, QueryEmbedding, toToolCallDelta, } from '@mcp-abap-adt/llm-agent';
10
+ import { ClaudeSkillManager, CodexSkillManager, ConfigWatcher, FileSystemPluginLoader, FileSystemSkillManager, getDefaultPluginDirs, HealthChecker, InMemoryKnowledgeBackend, KnowledgeRag, makeLlm, mergePluginExports, SessionGraphFactory, SessionLogger, SessionRegistry, SmartAgentBuilder, SmartAgentSubAgent, } from '@mcp-abap-adt/llm-agent-libs';
8
11
  import { MCPClientWrapper, McpClientAdapter, } from '@mcp-abap-adt/llm-agent-mcp';
9
12
  import { makeRag } from '@mcp-abap-adt/llm-agent-rag';
10
13
  import { PACKAGE_VERSION } from '../generated/version.js';
11
- import { resolveAgentEmbedder, resolveToolsStoreEmbedder, } from './resolve-agent-embedder.js';
14
+ import { resolveAgentEmbedder } from './resolve-agent-embedder.js';
12
15
  import { resolveSessionIdentity } from './session-identity-resolver.js';
13
16
  // ---------------------------------------------------------------------------
14
17
  // Helpers
@@ -67,12 +70,15 @@ const CORS_HEADERS = {
67
70
  // ---------------------------------------------------------------------------
68
71
  // SmartServer
69
72
  // ---------------------------------------------------------------------------
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 { ControllerPipelinePlugin } from '../pipelines/controller.js';
74
+ import { DagPipelinePlugin } from '../pipelines/dag.js';
75
+ import { FlatPipelinePlugin } from '../pipelines/flat.js';
76
+ import { LinearPipelinePlugin } from '../pipelines/linear.js';
77
+ import { createServerPipelineContext, } from '../pipelines/server-context.js';
78
+ import { StepperPipelinePlugin } from '../pipelines/stepper.js';
79
+ import { normalizeLlmConfig, resolveLlmConfig, resolveLlmConfigStrict, resolveToolSelectionStrategy, } from './config.js';
73
80
  import { JsonlKnowledgeBackend } from './jsonl-knowledge-backend.js';
74
81
  import { InMemorySessionMetaStore } from './session-meta-store.js';
75
- import { StepperCoordinatorHandler } from './stepper-coordinator-handler.js';
76
82
  export { generateConfigTemplate, loadYamlConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorPlanning, resolveEnvVars, resolveSmartServerConfig, resolveToolSelectionStrategy, YAML_TEMPLATE, } from './config.js';
77
83
  /**
78
84
  * Drain every cached worker's `close` (if any), then clear the cache map.
@@ -219,6 +225,7 @@ export function buildSessionLifecycle(opts) {
219
225
  ragRegistry: opts.ragRegistry,
220
226
  buildAgent: opts.buildAgent,
221
227
  logger: opts.logger,
228
+ onDispose: opts.onDispose,
222
229
  });
223
230
  const registry = new SessionRegistry({
224
231
  idleTtlMs: opts.idleTtlMs,
@@ -408,33 +415,6 @@ export function buildMcpBridge(clients) {
408
415
  return `Tool not found: ${name}`;
409
416
  };
410
417
  }
411
- // ---------------------------------------------------------------------------
412
- // Raw-config mode routing gate (R6-F2)
413
- // ---------------------------------------------------------------------------
414
- /**
415
- * Returns `true` ONLY when the raw coordinator config has an explicit `mode`
416
- * string — the opt-in gate for the 18.0 Stepper runtime.
417
- *
418
- * IMPORTANT: Do NOT call `parseStepperCoordinatorConfig` to decide — that
419
- * function defaults `mode` to `'planned-react'`, which would silently route
420
- * every 17.0 legacy config onto the Stepper path. Gate on the RAW field
421
- * presence only; parse is done INSIDE the Stepper branch after this check.
422
- */
423
- export function usesStepper(coordCfg) {
424
- if (!coordCfg)
425
- return false;
426
- // Explicit opt-in: `coordinator.mode` (preset alias) — a string in raw config.
427
- if (typeof coordCfg.mode === 'string')
428
- return true;
429
- // OR an explicit `coordinator.flow` composition block (mode-less form). A flow
430
- // with no mode is still a Stepper config; without this it would silently fall
431
- // through to the plain smart pipeline.
432
- if (typeof coordCfg.flow === 'object' && coordCfg.flow !== null)
433
- return true;
434
- // `coordinator.planner` without `coordinator.mode` selects the DAG coordinator
435
- // variant (a RETAINED peer pipeline, not deprecated) — not the Stepper.
436
- return false;
437
- }
438
418
  export class SmartServer {
439
419
  cfg;
440
420
  noop = () => { };
@@ -453,17 +433,30 @@ export class SmartServer {
453
433
  _fileLogger;
454
434
  _mergedEmbedderFactories;
455
435
  /**
456
- * Captured DAG coordinator template — `deps` are stateless or LLM-bound and
457
- * are reused across sessions; only `workers` + `stateOracle` are re-wired per
458
- * session (review HIGH #1).
436
+ * The embedder resolved ONCE in `start()` (`resolveAgentEmbedder` over
437
+ * `rag.embedder` / `embedder` config). Held so `buildServerCtx` can hand it to
438
+ * every pipeline context (the controller pipeline needs it for target-state
439
+ * semantic distance). Undefined when no embedder is configured.
459
440
  */
460
- _dagCoordinatorTemplate;
441
+ _resolvedEmbedder;
442
+ /** Normalized LLM map + pipeline fallback + main temperature — captured in
443
+ * `start()` so buildServerCtx can hand the raw role-LLM materials to the
444
+ * context factory (mirrors the inline DAG/linear resolution). */
445
+ _llmMap;
446
+ _pipelineFallback;
447
+ _mainTemp;
448
+ _requestLogger;
449
+ /** ToolsRag handle built by `buildSharedPipelineInfra`; handed to every
450
+ * pipeline's context (factory defaults to EMPTY_TOOLS_RAG if unset). */
451
+ _toolsRagHandle;
461
452
  /**
462
- * 18.0 Stepper coordinator handler — stateless, session context comes through
463
- * `ctx.sessionId` at execute time. Built once in `start()` when
464
- * `coordinator.mode` is present in the raw config; reused across sessions.
453
+ * The tools-RAG `IRag` (the store the builder vectorizes MCP `tool:<name>`
454
+ * docs into) captured in `start()`. Held so the `flat`/`smart` pipeline's
455
+ * `ToolSelectHandler` can select MCP tools from RAG hits — and so tests can
456
+ * assert the YAML-path vectorization landed. Distinct from `_toolsRagHandle`
457
+ * (the stepper catalog handle), which falls back to catalog order regardless.
465
458
  */
466
- _stepperCoordinatorHandler;
459
+ _toolsRag;
467
460
  /**
468
461
  * MCP clients connected for the Stepper path from the YAML `mcp:` config
469
462
  * block. These are connected ONCE in `start()` (lazily resolved by
@@ -474,6 +467,13 @@ export class SmartServer {
474
467
  * yaml). Disposed via the server's `closeFns` on shutdown.
475
468
  */
476
469
  _stepperMcpClients;
470
+ /**
471
+ * The MCP clients the pipeline `callMcp` bridge dispatches over — resolved
472
+ * UNCONDITIONALLY in `start()` as DI/plugin clients (`mcpClients`) ?? the
473
+ * YAML-connected `_stepperMcpClients`. Held so every pipeline (not just the
474
+ * stepper) gets a working `ctx.callMcp` without opening a second connection.
475
+ */
476
+ _sharedMcpClients;
477
477
  /**
478
478
  * The ONE shared knowledge backend for the Stepper path (set during build).
479
479
  * Held so DELETE /v1/sessions/:id can evict a session's entries from it —
@@ -487,6 +487,20 @@ export class SmartServer {
487
487
  * `cfg.sessionMetaStore` in a future extension.
488
488
  */
489
489
  _sessionMetaStore = new InMemorySessionMetaStore();
490
+ /**
491
+ * Pipeline-plugin registry, populated in `start()` after plugins load: the 4
492
+ * built-ins (flat/linear/dag/stepper) plus any `plugins.pipelinePlugins`,
493
+ * fail-fast on name collision. `buildPipelineInstance` selects by
494
+ * `cfg.pipeline.name` (default 'flat').
495
+ */
496
+ _pipelineRegistry;
497
+ /**
498
+ * Per-session `IPipelineInstance.close()` hooks, keyed by sessionId. Populated
499
+ * by `buildPipelineInstance` (via `buildSessionAgent`) and invoked from the
500
+ * session lifecycle `onDispose` so per-session pipeline resources (MCP / builder
501
+ * handles owned by the plugin) are freed on eviction / shutdown / reconfigure.
502
+ */
503
+ _sessionCloseFns = new Map();
490
504
  constructor(config) {
491
505
  this.cfg = config;
492
506
  }
@@ -496,62 +510,54 @@ export class SmartServer {
496
510
  log: (e) => log(e),
497
511
  };
498
512
  this._fileLogger = fileLogger;
499
- const pipeline = this.cfg.pipeline;
500
513
  // ---- Composition root: resolve config → interfaces --------------------
501
- // LLM resolution — normalize flat/map cfg.llm and derive pipeline fallback.
514
+ // LLM resolution — normalize the flat/map top-level `llm:` block. The legacy
515
+ // per-pipeline `pipeline.llm.*` override is gone; role LLMs derive entirely
516
+ // from the top-level map (resolveLlmConfig falls back to map.main), so the
517
+ // pipelineFallback chain is no longer fed a separate config — it stays
518
+ // undefined and the map.main fallback in resolveLlmConfig covers it.
502
519
  const llmMap = normalizeLlmConfig(this.cfg.llm);
503
- // Adapt pipeline.llm.main (uses `baseURL`) → SmartServerLlmConfig (uses
504
- // `url`) so resolveLlmConfig's pipelineFallback parameter speaks one
505
- // shape. Returns undefined when no pipeline.llm.main is configured.
506
- const pipelineFallback = (() => {
507
- const pm = pipeline?.llm?.main;
508
- if (!pm)
509
- return undefined;
510
- return {
511
- provider: pm.provider,
512
- apiKey: pm.apiKey ?? '',
513
- url: pm.baseURL,
514
- model: pm.model,
515
- temperature: pm.temperature,
516
- };
517
- })();
520
+ const pipelineFallback = undefined;
518
521
  const topMain = resolveLlmConfig(llmMap, 'main', pipelineFallback);
519
- const mainTemp = Number(pipeline?.llm?.main?.temperature ?? topMain?.temperature ?? 0.7);
520
- const mainLlm = pipeline?.llm?.main
521
- ? await makeLlm(pipeline.llm.main, mainTemp)
522
- : topMain
523
- ? await makeLlm({
524
- provider: topMain.provider ?? 'deepseek',
525
- apiKey: topMain.apiKey,
526
- baseURL: topMain.url,
527
- model: topMain.model,
528
- }, mainTemp)
529
- : (() => {
530
- throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
531
- })();
532
- const classifierTemp = Number(pipeline?.llm?.classifier?.temperature ??
533
- topMain?.classifierTemperature ??
534
- 0.1);
535
- const classifierLlm = pipeline?.llm?.classifier
536
- ? await makeLlm(pipeline.llm.classifier, classifierTemp)
537
- : pipeline?.llm?.main
538
- ? await makeLlm(pipeline.llm.main, classifierTemp)
539
- : topMain
540
- ? await makeLlm({
541
- provider: topMain.provider ?? 'deepseek',
542
- apiKey: topMain.apiKey,
543
- baseURL: topMain.url,
544
- model: topMain.model,
545
- }, classifierTemp)
546
- : (() => {
547
- throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
548
- })();
549
- const helperLlm = pipeline?.llm?.helper
550
- ? await makeLlm(pipeline.llm.helper, Number(pipeline.llm.helper.temperature ?? 0.1))
522
+ const mainTemp = Number(topMain?.temperature ?? 0.7);
523
+ const mainLlm = topMain
524
+ ? await makeLlm({
525
+ provider: topMain.provider ?? 'deepseek',
526
+ apiKey: topMain.apiKey,
527
+ baseURL: topMain.url,
528
+ model: topMain.model,
529
+ }, mainTemp)
530
+ : (() => {
531
+ throw new Error('no LLM configured: provide top-level llm.main');
532
+ })();
533
+ const classifierTemp = Number(topMain?.classifierTemperature ?? 0.1);
534
+ const classifierLlm = topMain
535
+ ? await makeLlm({
536
+ provider: topMain.provider ?? 'deepseek',
537
+ apiKey: topMain.apiKey,
538
+ baseURL: topMain.url,
539
+ model: topMain.model,
540
+ }, classifierTemp)
541
+ : (() => {
542
+ throw new Error('no LLM configured: provide top-level llm.main');
543
+ })();
544
+ // A 'helper' role LLM derives from the top-level `llm:` map when present
545
+ // (built only when an explicit map entry exists).
546
+ const helperCfg = resolveLlmConfigStrict(llmMap, 'helper');
547
+ const helperLlm = helperCfg
548
+ ? await makeLlm({
549
+ provider: helperCfg.provider ?? 'deepseek',
550
+ apiKey: helperCfg.apiKey,
551
+ baseURL: helperCfg.url,
552
+ model: helperCfg.model,
553
+ }, Number(helperCfg.temperature ?? 0.1))
551
554
  : undefined;
552
555
  this._mainLlm = mainLlm;
553
556
  this._classifierLlm = classifierLlm;
554
557
  this._helperLlm = helperLlm;
558
+ this._llmMap = llmMap;
559
+ this._pipelineFallback = pipelineFallback;
560
+ this._mainTemp = mainTemp;
555
561
  // ---- Plugin loader -------------------------------------------------------
556
562
  const pluginLoader = this.cfg.pluginLoader ??
557
563
  (() => {
@@ -580,6 +586,52 @@ export class SmartServer {
580
586
  if (plugins.errors.length > 0) {
581
587
  log({ event: 'plugin_errors', errors: plugins.errors });
582
588
  }
589
+ // ---- Explicit plugin specifiers (`plugins: [...]`) -------------------
590
+ // Dynamically import each module specifier and merge its FULL
591
+ // PluginExports (pipelinePlugins, embedderFactories, mcpClients, …) into
592
+ // the same LoadedPlugins object. Done BEFORE the embedder/RAG build below
593
+ // so plugin-supplied embedder factories are visible.
594
+ const requireFromCwd = createRequire(`${process.cwd()}/`);
595
+ for (const spec of this.cfg.plugins ?? []) {
596
+ // Resolve to an ABSOLUTE path against the USER's cwd, then import via
597
+ // a file URL. A bare `await import('./x.js')` would resolve relative to
598
+ // smart-server.js, not the user's cwd.
599
+ const abs = spec.startsWith('.')
600
+ ? pathResolve(process.cwd(), spec)
601
+ : spec.startsWith('/')
602
+ ? spec
603
+ : requireFromCwd.resolve(spec);
604
+ const mod = (await import(pathToFileURL(abs).href));
605
+ const registered = mergePluginExports(plugins, mod, spec);
606
+ log({ event: 'plugin_specifier_loaded', spec, registered });
607
+ }
608
+ // ---- Pipeline-plugin registry (sub-goal C) ---------------------------
609
+ // The 4 built-ins are STATIC; plugin-supplied pipelines are merged on top.
610
+ // Fail-fast on a name collision so a plugin cannot silently shadow a
611
+ // built-in (or another plugin). `buildPipelineInstance` selects by
612
+ // `cfg.pipeline.name` (default 'flat') at session-build time.
613
+ const pipelineRegistry = new Map();
614
+ for (const builtin of [
615
+ new FlatPipelinePlugin(),
616
+ new LinearPipelinePlugin(),
617
+ new DagPipelinePlugin(),
618
+ new StepperPipelinePlugin(),
619
+ new ControllerPipelinePlugin(),
620
+ ]) {
621
+ pipelineRegistry.set(builtin.name, builtin);
622
+ }
623
+ for (const [name, plugin] of plugins.pipelinePlugins) {
624
+ if (pipelineRegistry.has(name)) {
625
+ throw new Error(`pipeline plugin name collision: '${name}' is already registered ` +
626
+ '(built-in or another plugin)');
627
+ }
628
+ pipelineRegistry.set(name, plugin);
629
+ }
630
+ this._pipelineRegistry = pipelineRegistry;
631
+ log({
632
+ event: 'pipeline_registry_loaded',
633
+ pipelines: [...pipelineRegistry.keys()],
634
+ });
583
635
  // Merge plugin embedder factories with config-provided ones
584
636
  const mergedEmbedderFactories = {
585
637
  ...plugins.embedderFactories,
@@ -588,124 +640,85 @@ export class SmartServer {
588
640
  this._mergedEmbedderFactories = mergedEmbedderFactories;
589
641
  // Resolve the embedder ONCE so the same instance feeds both makeRag and the
590
642
  // subagent context-builder's toolSource (#137). See resolve-agent-embedder.
591
- let resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this.cfg.embedder, mergedEmbedderFactories);
592
- // ---- Build agent via Builder (interface-only) -------------------------
593
- let builder = new SmartAgentBuilder({
594
- mcp: pipeline?.mcp ?? this.cfg.mcp,
595
- agent: this.cfg.agent,
596
- prompts: this.cfg.prompts,
597
- skipModelValidation: this.cfg.skipModelValidation,
598
- })
599
- .withMainLlm(mainLlm)
600
- .withClassifierLlm(classifierLlm)
601
- .withLogger(fileLogger)
602
- .withMode(this.cfg.mode ?? 'smart');
603
- if (helperLlm) {
604
- builder = builder.withHelperLlm(helperLlm);
605
- }
643
+ const resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this.cfg.embedder, mergedEmbedderFactories);
644
+ // Hold the resolved embedder so buildServerCtx can thread it onto every
645
+ // pipeline context (the controller pipeline needs it for target-state).
646
+ this._resolvedEmbedder = resolvedEmbedder;
647
+ // ---- RAG resolution (interface-only) ----------------------------------
648
+ // Resolve the tools/history stores and any named collections HERE so the
649
+ // coordinator gate below can read the final `toolsRag`/`resolvedEmbedder`,
650
+ // then hand the ready stores to buildBaseBuilder for wiring.
606
651
  let toolsRag;
652
+ let historyRag;
653
+ const ragCollections = [];
607
654
  if (this.cfg.rag) {
608
655
  const ragOptions = {
609
656
  injectedEmbedder: resolvedEmbedder,
610
657
  extraFactories: mergedEmbedderFactories,
611
658
  };
612
659
  toolsRag = await makeRag(this.cfg.rag, ragOptions);
613
- builder = builder.setToolsRag(toolsRag);
614
- builder = builder.setHistoryRag(await makeRag({ ...this.cfg.rag }, ragOptions));
615
- }
616
- // Wire pipeline.rag.{name} stores into the builder so multi-store YAML
617
- // configs behave the same as the flat `rag:` block: `tools` and `history`
618
- // map to the agent's built-in slots; everything else is registered as a
619
- // named collection that the registry projection exposes via ragStores[name].
620
- if (pipeline?.rag) {
621
- for (const [name, storeCfg] of Object.entries(pipeline.rag)) {
622
- // The `tools` store feeds the subagent context-builder's toolSource
623
- // (mainEmbedder below). If the flat `rag:` block produced no embedder
624
- // (YAML-only multi-store deployments), resolve one from this store's
625
- // own config so the tools store and the context-builder share a single
626
- // embedder instance (#141, mirrors the flat-path fix in #137). Other
627
- // stores keep the raw DI embedder and are never overridden.
628
- if (name === 'tools') {
629
- resolvedEmbedder = await resolveToolsStoreEmbedder(resolvedEmbedder, storeCfg, this.cfg.embedder, mergedEmbedderFactories);
630
- }
631
- const ragOptions = {
632
- injectedEmbedder: name === 'tools'
633
- ? (resolvedEmbedder ?? this.cfg.embedder)
634
- : this.cfg.embedder,
635
- extraFactories: mergedEmbedderFactories,
636
- };
637
- const rag = await makeRag(storeCfg, ragOptions);
638
- if (name === 'tools') {
639
- toolsRag = rag;
640
- builder = builder.setToolsRag(rag);
641
- }
642
- else if (name === 'history') {
643
- builder = builder.setHistoryRag(rag);
644
- }
645
- else {
646
- builder = builder.addRagCollection({
647
- name,
648
- rag,
649
- meta: { displayName: name, scope: 'global' },
650
- });
651
- }
652
- }
653
- }
654
- if (this.cfg.circuitBreaker) {
655
- builder = builder.withCircuitBreaker(this.cfg.circuitBreaker);
656
- }
657
- if (plugins.reranker) {
658
- builder = builder.withReranker(plugins.reranker);
659
- }
660
- if (plugins.queryExpander) {
661
- builder = builder.withQueryExpander(plugins.queryExpander);
662
- }
663
- if (plugins.outputValidator) {
664
- builder = builder.withOutputValidator(plugins.outputValidator);
665
- }
666
- // Skill manager (DI > YAML config > plugin)
667
- const skillManager = this.cfg.skillManager ??
668
- plugins.skillManager ??
669
- resolveSkillManager(this.cfg.skills);
670
- if (skillManager) {
671
- builder = builder.withSkillManager(skillManager);
672
- }
673
- // LLM call strategy (from agent config)
674
- const strategyName = this.cfg.agent?.llmCallStrategy;
675
- if (strategyName) {
676
- const { StreamingLlmCallStrategy, NonStreamingLlmCallStrategy, FallbackLlmCallStrategy, } = await import('@mcp-abap-adt/llm-agent');
677
- const strategies = {
678
- streaming: () => new StreamingLlmCallStrategy(),
679
- 'non-streaming': () => new NonStreamingLlmCallStrategy(),
680
- fallback: () => new FallbackLlmCallStrategy(fileLogger),
681
- };
682
- const factory = strategies[strategyName];
683
- if (factory) {
684
- builder = builder.withLlmCallStrategy(factory());
685
- }
686
- }
687
- // Tool-selection strategy (from agent.toolSelection config)
688
- const toolSelectionCfg = this.cfg.agent?.toolSelection;
689
- if (toolSelectionCfg?.strategy) {
690
- builder = builder.withToolSelectionStrategy(resolveToolSelectionStrategy(toolSelectionCfg.strategy, {
691
- minScore: toolSelectionCfg.minScore,
692
- }));
693
- }
694
- // MCP clients (DI > plugin; YAML fallback handled by builder)
695
- const mcpClients = this.cfg.mcpClients ??
660
+ historyRag = await makeRag({ ...this.cfg.rag }, ragOptions);
661
+ }
662
+ // Capture the tools store for the flat/smart pipeline's ToolSelectHandler
663
+ // (and white-box vectorization assertions). See field doc.
664
+ this._toolsRag = toolsRag;
665
+ // NOTE: the legacy per-pipeline named-RAG multistore (`pipeline.rag.{name}`)
666
+ // is GONE with the `pipeline: {name,config}` migration. The top-level `rag:`
667
+ // block above is the single source of truth for the tools/history stores.
668
+ // Deployments that previously declared `pipeline.rag.{name}` collections must
669
+ // move them to top-level `rag:` (or register them as plugin RAG).
670
+ // MCP clients (DI > plugin > YAML). The YAML `mcp:` block is NOT pre-connected
671
+ // here — see the branch below.
672
+ const diOrPluginMcpClients = this.cfg.mcpClients ??
696
673
  (plugins.mcpClients.length > 0 ? plugins.mcpClients : undefined);
697
- if (mcpClients) {
698
- builder = builder.withMcpClients(mcpClients);
699
- }
700
- // Client adapters (DI > plugin; ClineClientAdapter is always registered as default)
701
- const { ClineClientAdapter } = await import('@mcp-abap-adt/llm-agent');
702
- const adapterSources = [
703
- ...(this.cfg.clientAdapters ?? []),
704
- ...plugins.clientAdapters,
705
- new ClineClientAdapter(),
706
- ];
707
- for (const adapter of adapterSources) {
708
- builder = builder.withClientAdapter(adapter);
674
+ // ---- Knowledge backend (no MCP dependency) ----------------------------
675
+ // The remaining shared pipeline infra (`_sharedMcpClients` + the
676
+ // `_toolsRagHandle` MCP catalog) is MCP-client-dependent and is resolved
677
+ // per-branch below — for the YAML-only path it must run AFTER `build()`
678
+ // connects + vectorizes (the builder owns that single connection).
679
+ this.buildKnowledgeBackend();
680
+ // ---- MCP connection strategy (exactly ONE connection) -----------------
681
+ // Two client sources, two orderings — both keep ONE MCP connection AND a
682
+ // vectorized `toolsRag`:
683
+ //
684
+ // • DI/plugin clients present → inject them into the startup builder via
685
+ // `withMcpClients` (builder.ts:923 short-circuits its own `cfg.mcp`
686
+ // auto-connect). These pre-built clients were never vectorized by the
687
+ // builder (unchanged behavior). `_sharedMcpClients` = that exact set,
688
+ // and the `_toolsRagHandle` catalog is built now over them.
689
+ //
690
+ // • YAML-only (no DI/plugin) → do NOT pre-connect and do NOT inject. The
691
+ // startup builder receives `cfg.mcp` (via buildBaseBuilder, since
692
+ // `mcpClients` is undefined) so `build()` CONNECTS the YAML block AND
693
+ // VECTORIZES the tools into `toolsRag` (the `IRag`). AFTER `build()` we
694
+ // harvest its connected set into `_sharedMcpClients` so `ctx.callMcp`
695
+ // and per-session agents reuse the SAME single connection, then build
696
+ // the `_toolsRagHandle` catalog over them. (Restores the
697
+ // tool-vectorization the inject-skip regressed, with no double-connect.)
698
+ // DI precedence semantics: an explicitly-provided client set (even an EMPTY
699
+ // array) overrides YAML `mcp:`. `cfg.mcpClients: []` is a deliberate "disable
700
+ // MCP / override plugin+YAML" signal — it must take the DI branch (inject `[]`
701
+ // → builder short-circuits via withMcpClients([]) → no YAML auto-connect), NOT
702
+ // fall through to the YAML branch. So gate on presence (`!== undefined`), not
703
+ // length. (`diOrPluginMcpClients` is already undefined when neither DI nor a
704
+ // non-empty plugin set was provided — see its resolution above.)
705
+ const hasDiOrPlugin = diOrPluginMcpClients !== undefined;
706
+ let mcpClients;
707
+ if (hasDiOrPlugin) {
708
+ // DI/plugin branch — resolve `_sharedMcpClients` + tools-RAG handle NOW
709
+ // (knowledge backend is idempotent; already built above).
710
+ await this.buildSharedPipelineInfra({
711
+ toolsRag,
712
+ resolvedEmbedder,
713
+ mcpClients: diOrPluginMcpClients,
714
+ });
715
+ mcpClients = diOrPluginMcpClients;
716
+ }
717
+ else {
718
+ // YAML-only / no-mcp branch — let the builder connect + vectorize from
719
+ // `cfg.mcp`; `_sharedMcpClients` + the tools-RAG handle are resolved from
720
+ // the built handle AFTER `build()` (see below).
721
+ mcpClients = undefined;
709
722
  }
710
723
  // Build SubAgentRegistry from `subagents:` YAML block (if present).
711
724
  // Each sub-agent is a minimal SmartAgent reusing the parent's plugin
@@ -724,323 +737,49 @@ export class SmartServer {
724
737
  hasDescription: typeof sub.description === 'string' && sub.description.length > 0,
725
738
  });
726
739
  }
727
- builder = builder.withSubAgents(registry);
728
- }
729
- // ---- Coordinator (autonomous plan-execute loop) ------------------------
730
- const coordCfg = this.cfg.coordinatorYaml;
731
- if (coordCfg) {
732
- const rawCoordCfg = coordCfg;
733
- if (usesStepper(rawCoordCfg)) {
734
- // ── 18.0 Stepper path (opt-in via coordinator.mode) ──────────────────
735
- // Parse only INSIDE this branch so parseStepperCoordinatorConfig's
736
- // mode default ('planned-react') never fires for legacy configs (R6-F2).
737
- const stepperCfg = parseStepperCoordinatorConfig(rawCoordCfg);
738
- const logDir = this.cfg.logDir;
739
- // KnowledgeRag factory: ONE backend instance shared across all requests.
740
- // Both backends are keyed by sessionId internally, so a single instance
741
- // gives correct per-session isolation AND persistence across same-cookie
742
- // requests within the process. JsonlKnowledgeBackend (logDir set) adds
743
- // durability across restarts; InMemoryKnowledgeBackend is the in-process
744
- // default. (Previously a fresh InMemoryKnowledgeBackend was created per
745
- // call, so subsequent same-session requests lost prior entries and were
746
- // re-seeded — review fix.)
747
- const knowledgeBackend = logDir
748
- ? new JsonlKnowledgeBackend(logDir)
749
- : new InMemoryKnowledgeBackend();
750
- // Held on the instance so DELETE /v1/sessions/:id can evict a session's
751
- // knowledge from THIS backend (not just remove the JSONL file).
752
- this._stepperKnowledgeBackend = knowledgeBackend;
753
- const knowledgeRagFor = async (sessionId) => {
754
- const kr = new KnowledgeRag(knowledgeBackend, sessionId);
755
- // Seed deployment-supplied guidance into a BRAND-NEW session only
756
- // (idempotent on resume). Config DATA — keeps the runtime MCP-agnostic.
757
- await seedSessionKnowledge(kr, stepperCfg.knowledgeSeed, new Date().toISOString());
758
- return kr;
759
- };
760
- // ToolsRag handle: real adapter over the server's tools RAG store + MCP
761
- // catalog. Implements IToolsRagHandle for the Stepper runtime:
762
- // query(text, k) — semantic search via toolsRag+embedder when available;
763
- // catalog-order fallback when neither is present.
764
- // lookup(name) — returns the tool schema from the MCP catalog by name
765
- // (populated lazily on first query()).
766
- //
767
- // Catalog note: when DI/plugin clients are present they are already
768
- // connected. When the config carries a YAML `mcp:` block instead, the
769
- // builder connects those clients INSIDE builder.build() and never
770
- // exposes them here. We therefore connect the YAML mcp config ONCE and
771
- // cache the result in _stepperMcpClients so every subsequent Stepper
772
- // request reuses the same live connections without reconnecting.
773
- //
774
- // DI precedence: this.cfg.mcpClients > plugin clients > yaml mcp config.
775
- // NOTE: connection is NOT safe to invoke twice on the same wrapper, so
776
- // the cache guard below is critical — do NOT move this inside a
777
- // per-request factory.
778
- if (!mcpClients && !this._stepperMcpClients) {
779
- const yamlMcp = pipeline?.mcp ?? this.cfg.mcp;
780
- this._stepperMcpClients = await connectMcpClientsFromConfig(yamlMcp);
781
- }
782
- const stepperMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
783
- // Lazily-populated catalog: name → LlmTool.
784
- let catalogCache;
785
- const ensureCatalog = async () => {
786
- if (catalogCache)
787
- return catalogCache;
788
- const catalog = new Map();
789
- await Promise.allSettled(stepperMcpClients.map(async (client) => {
790
- const result = await client.listTools();
791
- if (result.ok) {
792
- for (const t of result.value) {
793
- if (!catalog.has(t.name)) {
794
- catalog.set(t.name, t);
795
- }
796
- }
797
- }
798
- }));
799
- catalogCache = catalog;
800
- return catalog;
801
- };
802
- const toolsRagHandle = {
803
- async query(text, k) {
804
- const limit = k ?? 20;
805
- const catalog = await ensureCatalog();
806
- // Semantic path: requires both a vector store and an embedder.
807
- if (toolsRag && resolvedEmbedder) {
808
- const embedding = new QueryEmbedding(text, resolvedEmbedder);
809
- const ragResult = await toolsRag.query(embedding, limit);
810
- if (ragResult.ok) {
811
- const hits = [];
812
- for (const r of ragResult.value) {
813
- const id = r.metadata.id;
814
- if (id?.startsWith('tool:')) {
815
- const name = id.slice(5).replace(/:.*$/, '');
816
- const tool = catalog.get(name);
817
- if (tool)
818
- hits.push(tool);
819
- }
820
- }
821
- if (hits.length > 0)
822
- return hits;
823
- }
824
- }
825
- // Catalog-order fallback: return first `limit` tools from the MCP catalog.
826
- return [...catalog.values()].slice(0, limit);
827
- },
828
- lookup(name) {
829
- // Synchronous: returns from the in-memory cache populated by ensureCatalog.
830
- // Returns undefined before the first query() call — callers that need
831
- // a schema before any query should call query() first.
832
- return catalogCache?.get(name);
833
- },
834
- };
835
- // Mint helpers: stable UUIDs per spec §C.1/§C.2.
836
- // The server wires randomUUID(); tests can inject deterministic minters.
837
- const mintStepperId = () => randomUUID();
838
- const mintTurnId = () => randomUUID();
839
- const stepperMakeLlm = async (lc) => makeLlm({
840
- provider: lc.provider ?? 'deepseek',
841
- apiKey: lc.apiKey,
842
- baseURL: lc.url,
843
- model: lc.model,
844
- }, Number(lc.temperature ?? mainTemp));
845
- // Real callMcp bridge — delegates to the exported buildMcpBridge helper
846
- // that iterates stepperMcpClients. Exported for testability (B-1).
847
- const callMcp = buildMcpBridge(stepperMcpClients);
848
- // DI/test override registry — left empty in production so buildStepperRoot
849
- // builds the recursive child Steppers itself from `subagents` (below),
850
- // sharing this run's role LLMs + shared token ledger.
851
- const stepperRegistry = new Map();
852
- // Declared subagents (name + description) → advertised to the planner and
853
- // turned into recursive child Steppers in deep-stepper mode (Finding 1).
854
- const stepperSubagents = (this.cfg.subAgentConfigs ?? []).map((s) => ({
855
- name: s.name,
856
- description: s.description,
857
- }));
858
- const stepperHandler = new StepperCoordinatorHandler({
859
- buildBuilt: async (_ctx, logLlmCall) => {
860
- // Per-RUN MCP result cache. Identical (tool, args) calls within ONE
861
- // run reuse the first result instead of re-hitting MCP — fixes the
862
- // redundant re-reads we saw in flow (gather → analyze → synthesize
863
- // each fetching the same source/includes) and the latency that
864
- // caused. Caching the Promise also dedups concurrent identical
865
- // calls. Fresh per request → never stale across requests.
866
- const runMcpCache = new Map();
867
- const cachedCallMcp = (name, args, signal) => {
868
- // Same case-normalised identity key as the executor's dedup, so
869
- // the per-run cache and the dedup agree (and case-variant args —
870
- // F01 vs f01 — hit the same entry).
871
- const key = artifactIdentityKey(name, args);
872
- const hit = runMcpCache.get(key);
873
- if (hit)
874
- return hit;
875
- const p = callMcp(name, args, signal);
876
- runMcpCache.set(key, p);
877
- return p;
878
- };
879
- return buildStepperRoot({
880
- coordCfg: rawCoordCfg,
881
- registry: stepperRegistry,
882
- subagents: stepperSubagents,
883
- makeLlm: stepperMakeLlm,
884
- knowledgeRagFor: (sid) => {
885
- const backend = knowledgeBackend ?? new InMemoryKnowledgeBackend();
886
- return new KnowledgeRag(backend, sid);
887
- },
888
- toolsRag: toolsRagHandle,
889
- callMcp: cachedCallMcp,
890
- mintStepperId,
891
- llmMap,
892
- pipelineFallback,
893
- logLlmCall,
894
- });
895
- },
896
- knowledgeRagFor,
897
- toolsRag: toolsRagHandle,
898
- mintStepperId,
899
- mintTurnId,
900
- });
901
- this._stepperCoordinatorHandler = stepperHandler;
902
- builder = builder.withStepperCoordinator(stepperHandler);
903
- log({
904
- event: 'stepper_coordinator_configured',
905
- mode: stepperCfg.mode,
906
- config: rawCoordCfg,
907
- });
908
- }
909
- else if (coordCfg.planner !== undefined) {
910
- // ── DAG coordinator path (a RETAINED peer pipeline variant) ──────────
911
- // `coordinator.planner` (no `coordinator.mode`) selects the DAG
912
- // coordinator. It is NOT deprecated — it is the strongest single-shot
913
- // plan-graph variant (e.g. unaided full-include reviews). The Stepper
914
- // (`coordinator.mode` / `coordinator.flow`) is a sibling, not a
915
- // replacement. Informational only.
916
- log({
917
- event: 'config_info',
918
- message: 'coordinator.planner (no coordinator.mode) selects the DAG coordinator variant; ' +
919
- 'use coordinator.mode/flow for the Stepper variant',
920
- });
921
- // Fail loud if the config mixes DAG and linear fields.
922
- assertCoordinatorConfigShape(rawCoordCfg);
923
- // planner shape/type already validated by assertCoordinatorConfigShape.
924
- // Validate interpreter.type if present — only 'dag' is supported.
925
- const interpKind = coordCfg.interpreter?.type;
926
- if (interpKind !== undefined && interpKind !== 'dag') {
927
- throw new Error(`coordinator.interpreter: unknown type '${interpKind}' (only 'dag' is supported)`);
928
- }
929
- const built = await buildDagCoordinatorDeps({
930
- coordCfg: rawCoordCfg,
931
- llmMap,
932
- pipelineFallback,
933
- mainLlm,
934
- helperLlm,
935
- mainTemp,
936
- registry,
937
- makeLlm: async (lc) => makeLlm({
938
- provider: lc.provider ?? 'deepseek',
939
- apiKey: lc.apiKey,
940
- baseURL: lc.url,
941
- model: lc.model,
942
- }, Number(lc.temperature ?? mainTemp)),
943
- warn: (m) => log({ event: 'config_warning', message: m }),
944
- });
945
- // built is defined when coordCfg.planner !== undefined.
946
- if (!built) {
947
- throw new Error('internal: buildDagCoordinatorDeps returned undefined despite planner present');
948
- }
949
- const { oracleName, ...deps } = built;
950
- builder = builder.withDagCoordinator(deps);
951
- // Capture template for per-session re-wire (workers + stateOracle are
952
- // re-wired per session; everything else is stateless or LLM-bound).
953
- this._dagCoordinatorTemplate = {
954
- deps: {
955
- planner: deps.planner,
956
- interpreter: deps.interpreter,
957
- activation: deps.activation,
958
- reviewer: deps.reviewer,
959
- errorStrategy: deps.errorStrategy,
960
- finalizer: deps.finalizer,
961
- maxRoundTrips: deps.maxRoundTrips,
962
- },
963
- oracleName,
964
- };
965
- log({ event: 'dag_coordinator_configured', config: coordCfg });
966
- }
967
- else {
968
- // ── Linear / flat coordinator path ────────────────────────────────────
969
- // Fail loud if config mixes fields with non-linear settings.
970
- assertCoordinatorConfigShape(rawCoordCfg);
971
- // Linear mode: route plannerLlm through the normalized map chain.
972
- // Priority: map[name] → 'helper'/'planner' alias (helperLlm) →
973
- // pipelineFallback → mainLlm.
974
- const linearPlannerName = coordCfg.plannerLlm;
975
- // Role-resolution order:
976
- // 1. explicit map[name] → build from that entry
977
- // 2. name === 'helper' | 'planner' → reuse prebuilt helperLlm
978
- // 3. unknown name → resolveLlmConfig fallback chain (map.main → pipelineFallback)
979
- // 4. no name → mainLlm
980
- const linearPlannerCfgStrict = resolveLlmConfigStrict(llmMap, linearPlannerName);
981
- const plannerLlm = linearPlannerCfgStrict
982
- ? await makeLlm({
983
- provider: linearPlannerCfgStrict.provider ?? 'deepseek',
984
- apiKey: linearPlannerCfgStrict.apiKey,
985
- baseURL: linearPlannerCfgStrict.url,
986
- model: linearPlannerCfgStrict.model,
987
- }, Number(linearPlannerCfgStrict.temperature ?? mainTemp))
988
- : linearPlannerName === 'helper' || linearPlannerName === 'planner'
989
- ? (helperLlm ?? mainLlm)
990
- : linearPlannerName
991
- ? // Unknown name, not an alias — try pipelineFallback last.
992
- await (async () => {
993
- const fb = resolveLlmConfig(llmMap, linearPlannerName, pipelineFallback);
994
- return fb
995
- ? makeLlm({
996
- provider: fb.provider ?? 'deepseek',
997
- apiKey: fb.apiKey,
998
- baseURL: fb.url,
999
- model: fb.model,
1000
- }, Number(fb.temperature ?? mainTemp))
1001
- : mainLlm;
1002
- })()
1003
- : mainLlm;
1004
- const planningKind = coordCfg.planning ?? 'one-shot';
1005
- const dispatchKind = resolveCoordinatorDispatchKind(coordCfg.dispatch);
1006
- // Build the same kind of context builder SmartAgentBuilder uses, so
1007
- // YAML-configured deployments get the same default behavior. Uses the
1008
- // embedder resolved above — DI-injected (this.cfg.embedder) when present,
1009
- // otherwise constructed from `rag.embedder` (#137). Stays undefined for
1010
- // bare in-memory BM25 stores (no embedder), in which case toolSource is
1011
- // skipped and constrained subagents fall back to empty context.
1012
- const mainEmbedder = resolvedEmbedder;
1013
- const toolSource = mainEmbedder && toolsRag
1014
- ? async (text, k, signal) => {
1015
- const embedding = new QueryEmbedding(text, mainEmbedder, {
1016
- signal,
1017
- });
1018
- const r = await toolsRag.query(embedding, k, { signal });
1019
- return r.ok ? r.value : [];
1020
- }
1021
- : undefined;
1022
- const contextBuilder = new DefaultSubAgentContextBuilder({
1023
- toolSource,
1024
- });
1025
- builder = builder.withCoordinator({
1026
- planning: resolveCoordinatorPlanning(planningKind, plannerLlm),
1027
- dispatch: resolveCoordinatorDispatch(dispatchKind, plannerLlm, contextBuilder),
1028
- // Default to 'explicit' — the presence of a `coordinator:` block in
1029
- // YAML is itself the opt-in signal. Users that want the auto-fallback
1030
- // semantics (no subagents and no skill steps → tool-loop) must set
1031
- // `activation: auto` explicitly.
1032
- activation: resolveCoordinatorActivation(coordCfg.activation ?? 'explicit'),
1033
- plannerLlm,
1034
- maxSteps: coordCfg.maxSteps,
1035
- maxRetriesPerStep: coordCfg.maxRetriesPerStep,
1036
- failPolicy: coordCfg.failPolicy,
1037
- });
1038
- log({ event: 'coordinator_configured', config: coordCfg });
1039
- }
1040
740
  }
741
+ // ---- Build agent via Builder (interface-only) -------------------------
742
+ // Assemble everything EXCEPT the coordinator via the shared base-builder
743
+ // factory; the coordinator gate below wires the chosen variant.
744
+ const builder = await this.buildBaseBuilder({
745
+ mainLlm,
746
+ classifierLlm,
747
+ helperLlm,
748
+ fileLogger,
749
+ toolsRag,
750
+ historyRag,
751
+ ragCollections,
752
+ mcpClients,
753
+ plugins,
754
+ workerRegistry: registry,
755
+ applyServerExtras: true,
756
+ });
757
+ // ---- Startup global agent = INFRA + passthrough ONLY -------------------
758
+ // No coordinator is wired here. The startup global agent exists purely for
759
+ // infrastructure (/v1/models, /v1/embedding-models, HealthChecker), session
760
+ // lifecycle, passthrough, and cleanup — its coordinator would never be
761
+ // invoked because `_handleChat`/`_handleAdapterRequest` always dispatch to
762
+ // the PER-SESSION agent (`graph.agent`, built by `buildSessionAgent` →
763
+ // `buildPipelineInstance`); the startup agent is only the `?? smartAgent`
764
+ // fallback when no session graph exists. The previous 3-way coordinator gate
765
+ // (stepper / DAG / linear) was therefore dead on this path and is removed.
766
+ // Real coordinated request-serving lives entirely in the session pipeline.
767
+ // (Shared pipeline infra — knowledge backend, tools-RAG handle, MCP bridge —
768
+ // was hoisted UNCONDITIONALLY above via buildSharedPipelineInfra so every
769
+ // pipeline's buildServerCtx resolves its dep-sources.)
1041
770
  const agentHandle = await builder.build();
1042
771
  const { agent: smartAgent, chat, streamChat, close: closeAgent, circuitBreakers, ragStores, modelProvider, } = agentHandle;
1043
772
  const { ragRegistry: globalRagRegistry, mcpClients: globalMcpClients } = agentHandle;
773
+ // ---- YAML-only MCP harvest (single-connect + restored vectorization) ----
774
+ // For the YAML-only branch the startup builder connected the `mcp:` block
775
+ // AND vectorized its tools into `toolsRag`. Harvest the builder's connected
776
+ // set into `_sharedMcpClients` so `ctx.callMcp` and per-session agents reuse
777
+ // the SAME single connection (no second connect), then build the tools-RAG
778
+ // handle catalog over it. The DI/plugin branch already did this earlier.
779
+ if (!hasDiOrPlugin) {
780
+ this._sharedMcpClients = globalMcpClients ?? [];
781
+ await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
782
+ }
1044
783
  // ---- API adapter map (built-in → config DI; DI wins) --------------------
1045
784
  const { OpenAiApiAdapter, AnthropicApiAdapter } = await import('@mcp-abap-adt/llm-agent');
1046
785
  const adapterMap = new Map();
@@ -1074,10 +813,23 @@ export class SmartServer {
1074
813
  maxSessions: sessionCfg.maxSessions ?? 1000,
1075
814
  cookieName: sessionCfg.cookieName ?? 'sid',
1076
815
  mcpClients: globalMcpClients,
1077
- toolsRag,
816
+ // `this._toolsRag` === the `toolsRag` local captured in start(); reference
817
+ // the field as the single source of truth for the tools store.
818
+ toolsRag: this._toolsRag,
1078
819
  ragRegistry: globalRagRegistry,
1079
820
  buildAgent: (parts) => this.buildSessionAgent(parts),
1080
821
  logger: fileLogger,
822
+ // Per-session pipeline teardown: run the IPipelineInstance.close captured
823
+ // by buildPipelineInstance, then drop the entry. Wired here so eviction /
824
+ // shutdown / reconfigure (everything routed through SessionGraph.dispose)
825
+ // frees per-session pipeline resources (MCP / builder handles).
826
+ onDispose: async (sessionId) => {
827
+ const close = this._sessionCloseFns.get(sessionId);
828
+ if (close) {
829
+ this._sessionCloseFns.delete(sessionId);
830
+ await close();
831
+ }
832
+ },
1081
833
  });
1082
834
  this._lifecycle = lifecycle;
1083
835
  const sweepMs = Math.min(idleTtlMs, 60_000);
@@ -1265,11 +1017,11 @@ export class SmartServer {
1265
1017
  const subLlmMain = subLlmMap?.main;
1266
1018
  if (!subLlmMain?.apiKey &&
1267
1019
  subLlmMain?.provider !== 'sap-ai-sdk' &&
1268
- subLlmMain?.provider !== 'ollama' &&
1269
- !subCfg.pipeline?.llm?.main) {
1020
+ subLlmMain?.provider !== 'ollama') {
1270
1021
  throw new Error(`subagent '${name}': LLM API key is required`);
1271
1022
  }
1272
- const subPipeline = subCfg.pipeline;
1023
+ // The subagent's helper role derives from its own top-level `llm:` map.
1024
+ const subHelperCfg = resolveLlmConfigStrict(subLlmMap, 'helper');
1273
1025
  // LLM/embedder clients: when the per-session re-wire injected them, use
1274
1026
  // those cached instances by reference (NEVER reconstruct). Otherwise (the
1275
1027
  // primary build()), build-once via the cache so the global agent build
@@ -1287,37 +1039,34 @@ export class SmartServer {
1287
1039
  // cache hit short-circuits all factories. This keeps the worker's
1288
1040
  // declared RAG/MCP intact across per-session re-wires (review HIGH #1).
1289
1041
  const subFlatLlm = subLlmMain;
1290
- const mainTemp = Number(subPipeline?.llm?.main?.temperature ?? subFlatLlm?.temperature ?? 0.7);
1291
- const classifierTemp = Number(subPipeline?.llm?.classifier?.temperature ??
1292
- subFlatLlm?.classifierTemperature ??
1293
- 0.1);
1042
+ const mainTemp = Number(subFlatLlm?.temperature ?? 0.7);
1043
+ const classifierTemp = Number(subFlatLlm?.classifierTemperature ?? 0.1);
1294
1044
  const cached = await resolveWorkerLlmSet({
1295
1045
  name,
1296
1046
  cache: this._workerLlmCache,
1297
1047
  // Preserve the existing makeLlm derivation exactly.
1298
- makeMain: () => subPipeline?.llm?.main
1299
- ? makeLlm(subPipeline.llm.main, mainTemp)
1300
- : makeLlm({
1301
- // ?? 'deepseek' is a TS type-narrowing net only; the config
1302
- // validator rejects a missing flat-schema provider before
1303
- // this runs.
1304
- provider: subFlatLlm?.provider ?? 'deepseek',
1305
- apiKey: subFlatLlm?.apiKey ?? '',
1306
- baseURL: subFlatLlm?.url,
1307
- model: subFlatLlm?.model,
1308
- }, mainTemp),
1309
- makeClassifier: () => subPipeline?.llm?.classifier
1310
- ? makeLlm(subPipeline.llm.classifier, classifierTemp)
1311
- : subPipeline?.llm?.main
1312
- ? makeLlm(subPipeline.llm.main, classifierTemp)
1313
- : makeLlm({
1314
- provider: subFlatLlm?.provider ?? 'deepseek',
1315
- apiKey: subFlatLlm?.apiKey ?? '',
1316
- baseURL: subFlatLlm?.url,
1317
- model: subFlatLlm?.model,
1318
- }, classifierTemp),
1319
- makeHelper: subPipeline?.llm?.helper
1320
- ? ((h) => () => makeLlm(h, Number(h.temperature ?? 0.1)))(subPipeline.llm.helper)
1048
+ makeMain: () => makeLlm({
1049
+ // ?? 'deepseek' is a TS type-narrowing net only; the config
1050
+ // validator rejects a missing flat-schema provider before
1051
+ // this runs.
1052
+ provider: subFlatLlm?.provider ?? 'deepseek',
1053
+ apiKey: subFlatLlm?.apiKey ?? '',
1054
+ baseURL: subFlatLlm?.url,
1055
+ model: subFlatLlm?.model,
1056
+ }, mainTemp),
1057
+ makeClassifier: () => makeLlm({
1058
+ provider: subFlatLlm?.provider ?? 'deepseek',
1059
+ apiKey: subFlatLlm?.apiKey ?? '',
1060
+ baseURL: subFlatLlm?.url,
1061
+ model: subFlatLlm?.model,
1062
+ }, classifierTemp),
1063
+ makeHelper: subHelperCfg
1064
+ ? ((h) => () => makeLlm({
1065
+ provider: h.provider ?? 'deepseek',
1066
+ apiKey: h.apiKey,
1067
+ baseURL: h.url,
1068
+ model: h.model,
1069
+ }, Number(h.temperature ?? 0.1)))(subHelperCfg)
1321
1070
  : undefined,
1322
1071
  // Worker-OWN tools RAG (from subCfg.rag, if declared). Built once;
1323
1072
  // re-wired per-session by reference — never re-vectorized.
@@ -1345,7 +1094,7 @@ export class SmartServer {
1345
1094
  const classifierLlm = cached.classifierLlm;
1346
1095
  const helperLlm = cached.helperLlm;
1347
1096
  let subBuilder = new SmartAgentBuilder({
1348
- mcp: subPipeline?.mcp ?? subCfg.mcp,
1097
+ mcp: subCfg.mcp,
1349
1098
  agent: subCfg.agent,
1350
1099
  prompts: subCfg.prompts,
1351
1100
  skipModelValidation: subCfg.skipModelValidation,
@@ -1410,105 +1159,507 @@ export class SmartServer {
1410
1159
  }
1411
1160
  return handle.agent;
1412
1161
  }
1162
+ // -- Pipeline-context dep sources (promoted from the inline coordinator-gate
1163
+ // closures; consumed by buildServerCtx, which later tasks call) ----------
1164
+ /** Build an LLM from a SmartServerLlmConfig (mirrors stepperMakeLlm/DAG). */
1165
+ _makeLlm(lc) {
1166
+ return makeLlm({
1167
+ provider: lc.provider ?? 'deepseek',
1168
+ apiKey: lc.apiKey,
1169
+ baseURL: lc.url,
1170
+ model: lc.model,
1171
+ }, Number(lc.temperature ?? this._mainTemp ?? 0.7));
1172
+ }
1173
+ /** Resolve a per-role LLM through the normalized map → pipelineFallback chain.
1174
+ * 'main' returns the captured mainLlm; 'helper'/'classifier' return the
1175
+ * prebuilt instances when present; otherwise the map/fallback config is built. */
1176
+ async resolveRoleLlm(role) {
1177
+ if (role === 'main' && this._mainLlm)
1178
+ return this._mainLlm;
1179
+ if ((role === 'helper' || role === 'planner') && this._helperLlm) {
1180
+ return this._helperLlm;
1181
+ }
1182
+ if (role === 'classifier' && this._classifierLlm)
1183
+ return this._classifierLlm;
1184
+ const cfg = resolveLlmConfig(this._llmMap, role, this._pipelineFallback);
1185
+ if (cfg)
1186
+ return this._makeLlm(cfg);
1187
+ if (this._mainLlm)
1188
+ return this._mainLlm;
1189
+ throw new Error(`cannot resolve LLM for role '${role}': no config`);
1190
+ }
1413
1191
  /**
1414
- * Builds a per-session SmartAgent from injected globals (review HIGH #1 +
1415
- * MEDIUM #3). Re-wires the SubAgent registry + DAG coordinator deps FRESH per
1416
- * session, sharing this session's logger + the global ragRegistry/toolsRag/
1417
- * mcpClients + the CACHED per-worker LLM/embedder (this._workerLlmCache). It
1418
- * NEVER reuses the primary build()'s global `registry`/DAG-deps and NEVER
1419
- * constructs new LLM clients.
1192
+ * Session-scoped knowledge RAG over the shared knowledge backend (built
1193
+ * unconditionally in `start()`; a fresh in-memory backend is a defensive
1194
+ * fallback). HOST-level seeding happens HERE so the stepper plugin stays
1195
+ * agnostic (it just calls `ctx.knowledgeRagFor`): a BRAND-NEW session is
1196
+ * seeded from `pipeline.config.knowledgeSeed` (read defensively — absent for
1197
+ * non-stepper pipelines, where an empty seed is a harmless no-op). Idempotent
1198
+ * on resume via `seedSessionKnowledge`.
1420
1199
  */
1421
- async buildSessionAgent(parts) {
1422
- // Guard: globals must already be captured by the primary build() before any
1423
- // session graph is built (the registry calls this lazily on first acquire).
1424
- if (!this._mainLlm || !this._classifierLlm || !this._fileLogger) {
1425
- throw new Error('buildSessionAgent invoked before primary build() captured globals');
1200
+ async knowledgeRagFor(sessionId) {
1201
+ const backend = this._stepperKnowledgeBackend ?? new InMemoryKnowledgeBackend();
1202
+ const kr = new KnowledgeRag(backend, sessionId);
1203
+ const rawSeed = this.cfg.pipeline?.config
1204
+ ?.knowledgeSeed;
1205
+ const seeds = Array.isArray(rawSeed)
1206
+ ? rawSeed
1207
+ .filter((e) => e && typeof e.content === 'string')
1208
+ .map((e) => ({
1209
+ content: e.content,
1210
+ artifactType: typeof e.artifactType === 'string' ? e.artifactType : 'guidance',
1211
+ }))
1212
+ : [];
1213
+ await seedSessionKnowledge(kr, seeds, new Date().toISOString());
1214
+ return kr;
1215
+ }
1216
+ /** callMcp bridge over the shared connected MCP clients (empty when none). */
1217
+ callMcp(name, args, signal) {
1218
+ return buildMcpBridge(this._sharedMcpClients ?? [])(name, args, signal);
1219
+ }
1220
+ _mintStepperId() {
1221
+ return randomUUID();
1222
+ }
1223
+ _mintTurnId() {
1224
+ return randomUUID();
1225
+ }
1226
+ /**
1227
+ * Build the shared pipeline infra consumed by `buildServerCtx` for EVERY
1228
+ * pipeline (sub-goal 5). Populates, on the instance:
1229
+ * - `_stepperKnowledgeBackend` — the ONE knowledge backend (JSONL when a
1230
+ * logDir is set, else in-memory) shared across sessions; `knowledgeRagFor`
1231
+ * keys it by sessionId and host-seeds new sessions from
1232
+ * `pipeline.config.knowledgeSeed`.
1233
+ * - `_sharedMcpClients` — the connected clients the `callMcp` bridge
1234
+ * dispatches over: DI/plugin clients by reference, else the YAML `mcp:`
1235
+ * block connected ONCE here (never a second connection when DI clients
1236
+ * already exist).
1237
+ * - `_toolsRagHandle` — a real IToolsRagHandle over the tools RAG store +
1238
+ * MCP catalog (semantic when an embedder+store exist, catalog-order
1239
+ * fallback otherwise). Undefined toolsRag/embedder still yields a usable
1240
+ * catalog-backed handle.
1241
+ */
1242
+ async buildSharedPipelineInfra(input) {
1243
+ const { toolsRag, resolvedEmbedder, mcpClients } = input;
1244
+ this.buildKnowledgeBackend();
1245
+ // MCP clients for the callMcp bridge. DI/plugin clients win; otherwise
1246
+ // connect the YAML `mcp:` block ONCE (connect is not safe to invoke twice
1247
+ // on the same wrapper — guard via the cache field).
1248
+ if (!mcpClients && !this._stepperMcpClients) {
1249
+ this._stepperMcpClients = await connectMcpClientsFromConfig(this.cfg.mcp);
1250
+ }
1251
+ this._sharedMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
1252
+ await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
1253
+ }
1254
+ /**
1255
+ * Build the ONE knowledge backend shared across all requests (JSONL when a
1256
+ * logDir is set, else in-memory). Keyed by sessionId internally for per-session
1257
+ * isolation + same-cookie persistence. Idempotent (no-op once built).
1258
+ *
1259
+ * No MCP dependency — safe to call BEFORE the MCP client set is resolved.
1260
+ */
1261
+ buildKnowledgeBackend() {
1262
+ const logDir = this.cfg.logDir;
1263
+ if (!this._stepperKnowledgeBackend) {
1264
+ this._stepperKnowledgeBackend = logDir
1265
+ ? new JsonlKnowledgeBackend(logDir)
1266
+ : new InMemoryKnowledgeBackend();
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Build `_toolsRagHandle` — a real IToolsRagHandle over the tools RAG store +
1271
+ * MCP catalog, dispatching over the ALREADY-RESOLVED `this._sharedMcpClients`.
1272
+ *
1273
+ * Split out of `buildSharedPipelineInfra` so the YAML-only path can run it
1274
+ * AFTER the startup builder connects + vectorizes (the builder owns the single
1275
+ * connection there, and `_sharedMcpClients` is harvested from its handle). For
1276
+ * the DI/plugin path it still runs early via `buildSharedPipelineInfra`.
1277
+ * Requires `this._sharedMcpClients` to be set by the caller.
1278
+ */
1279
+ async buildToolsRagHandle(input) {
1280
+ const { toolsRag, resolvedEmbedder } = input;
1281
+ // Tools RAG handle over the tools store + MCP catalog.
1282
+ const stepperMcpClients = this._sharedMcpClients ?? [];
1283
+ let catalogCache;
1284
+ const ensureCatalog = async () => {
1285
+ if (catalogCache)
1286
+ return catalogCache;
1287
+ const catalog = new Map();
1288
+ await Promise.allSettled(stepperMcpClients.map(async (client) => {
1289
+ const result = await client.listTools();
1290
+ if (result.ok) {
1291
+ for (const t of result.value) {
1292
+ if (!catalog.has(t.name))
1293
+ catalog.set(t.name, t);
1294
+ }
1295
+ }
1296
+ }));
1297
+ catalogCache = catalog;
1298
+ return catalog;
1299
+ };
1300
+ this._toolsRagHandle = {
1301
+ async query(text, k, options) {
1302
+ const limit = k ?? 20;
1303
+ const catalog = await ensureCatalog();
1304
+ if (toolsRag && resolvedEmbedder) {
1305
+ // Pass options (requestLogger + trace) so the wrapped embedder logs
1306
+ // this query-embedding against the request.
1307
+ const embedding = new QueryEmbedding(text, resolvedEmbedder, options);
1308
+ const ragResult = await toolsRag.query(embedding, limit);
1309
+ if (ragResult.ok) {
1310
+ const hits = [];
1311
+ for (const r of ragResult.value) {
1312
+ const id = r.metadata.id;
1313
+ if (id?.startsWith('tool:')) {
1314
+ const name = id.slice(5).replace(/:.*$/, '');
1315
+ const tool = catalog.get(name);
1316
+ if (tool)
1317
+ hits.push(tool);
1318
+ }
1319
+ }
1320
+ if (hits.length > 0)
1321
+ return hits;
1322
+ }
1323
+ }
1324
+ return [...catalog.values()].slice(0, limit);
1325
+ },
1326
+ lookup(name) {
1327
+ return catalogCache?.get(name);
1328
+ },
1329
+ };
1330
+ // F2: eagerly populate the MCP tool catalog at startup (MCP is connected
1331
+ // above), so the SYNC `lookup(name)` contract (IToolsRagHandle.lookup) returns
1332
+ // a tool schema BEFORE any `query()` runs. `ensureCatalog` is idempotent —
1333
+ // later `query()` calls reuse the cached map. Guard against a catalog-load
1334
+ // failure so startup never crashes: on failure `catalogCache` stays unset and
1335
+ // `lookup` returns undefined (today's worst case), while the happy path works.
1336
+ try {
1337
+ await ensureCatalog();
1338
+ }
1339
+ catch (err) {
1340
+ this.cfg.log?.({
1341
+ event: 'tools_catalog_eager_load_failed',
1342
+ message: 'tools catalog eager-load failed; lookup() returns undefined until first query()',
1343
+ error: err instanceof Error ? err.message : String(err),
1344
+ });
1426
1345
  }
1427
- let b = new SmartAgentBuilder({
1346
+ }
1347
+ /**
1348
+ * Build the per-session pipeline instance from the registry. Selects the
1349
+ * plugin by `cfg.pipeline.name` (default 'flat'), parses its config dialect,
1350
+ * and builds it against a session-scoped pipeline context. The returned
1351
+ * `IPipelineInstance` carries `{ agent, close }` — the session consumes
1352
+ * `agent`; `buildSessionAgent` registers `close` into the session-dispose path.
1353
+ */
1354
+ async buildPipelineInstance(scope) {
1355
+ const name = this.cfg.pipeline?.name ?? 'flat';
1356
+ const plugin = this._pipelineRegistry.get(name);
1357
+ if (!plugin) {
1358
+ throw new Error(`unknown pipeline '${name}'; available: ${[
1359
+ ...this._pipelineRegistry.keys(),
1360
+ ].join(', ')}`);
1361
+ }
1362
+ const cfg = plugin.parseConfig(this.cfg.pipeline?.config ?? {});
1363
+ return plugin.build(cfg, await this.buildServerCtx(scope));
1364
+ }
1365
+ warn(msg) {
1366
+ (this.cfg.log ?? this.noop)({ event: 'config_warning', message: msg });
1367
+ }
1368
+ /**
1369
+ * Build the FRESH per-session worker (sub-agent) registry from the SAME
1370
+ * `subagents:` configs the primary build() used, injecting globals + this
1371
+ * session's logger + the CACHED per-worker LLM/embedder (this._workerLlmCache).
1372
+ * NEVER reconstructs LLM clients; NEVER reuses the global registry.
1373
+ *
1374
+ * Extracted from buildSessionAgent so both the legacy session re-wire and the
1375
+ * pipeline-plugin context (`buildServerCtx` / `partsToBaseInput`) feed a real
1376
+ * per-session worker map to `buildBaseBuilder` instead of an empty `new Map()`.
1377
+ */
1378
+ async buildWorkerRegistry(parts) {
1379
+ const registry = new Map();
1380
+ if (!this.cfg.subAgentConfigs || this.cfg.subAgentConfigs.length === 0) {
1381
+ return registry;
1382
+ }
1383
+ if (!this._fileLogger) {
1384
+ throw new Error('buildWorkerRegistry invoked before primary build() captured globals');
1385
+ }
1386
+ for (const sub of this.cfg.subAgentConfigs) {
1387
+ // Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
1388
+ // clears `_workerLlmCache`, the next session build used to throw
1389
+ // "worker LLM set not cached" because the cache was assumed
1390
+ // pre-populated by the primary build(). buildSubAgent itself routes
1391
+ // through `resolveWorkerLlmSet` which is build-on-miss, so calling
1392
+ // it without an `injected` arg rebuilds the cache entry. We then
1393
+ // re-read the entry to honour the per-worker slot priority below.
1394
+ if (!this._workerLlmCache.has(sub.name)) {
1395
+ await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
1396
+ }
1397
+ const cached = this._workerLlmCache.get(sub.name);
1398
+ if (!cached) {
1399
+ // Defence in depth — should be impossible after the lazy build
1400
+ // above unless buildSubAgent's contract changes.
1401
+ throw new Error(`worker LLM set not cached for '${sub.name}'`);
1402
+ }
1403
+ // Per-worker injected slot priority (review HIGH #7):
1404
+ // worker-cached (from the primary build, includes backfilled
1405
+ // subCfg.mcp / subCfg.rag results) → parent's session-scoped
1406
+ // fallback. Encoded HERE so buildSubAgent does not need to know
1407
+ // the difference; it just consumes injected.mcpClients/toolsRag.
1408
+ const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
1409
+ ? cached.mcpClients
1410
+ : parts.mcpClients;
1411
+ const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
1412
+ const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
1413
+ ragRegistry: parts.ragRegistry,
1414
+ toolsRag: injectedToolsRag,
1415
+ mcpClients: injectedMcpClients,
1416
+ requestLogger: parts.logger,
1417
+ mainLlm: cached.mainLlm,
1418
+ classifierLlm: cached.classifierLlm,
1419
+ helperLlm: cached.helperLlm,
1420
+ embedder: cached.embedder,
1421
+ });
1422
+ registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
1423
+ description: sub.description,
1424
+ }));
1425
+ }
1426
+ return registry;
1427
+ }
1428
+ /**
1429
+ * Map SessionAgentParts → buildBaseBuilder input. `workerRegistry` is the
1430
+ * pre-built per-session worker map (from buildWorkerRegistry). `extras`
1431
+ * carries the startup-only inputs (plugins + applyServerExtras + the global
1432
+ * history/collection stores); omitted for the session scope.
1433
+ */
1434
+ partsToBaseInput(parts, workerRegistry, extras) {
1435
+ return {
1436
+ mainLlm: this._mainLlm,
1437
+ classifierLlm: this._classifierLlm,
1438
+ helperLlm: this._helperLlm,
1439
+ fileLogger: this._fileLogger,
1440
+ toolsRag: parts.toolsRag,
1441
+ historyRag: extras?.historyRag,
1442
+ ragCollections: extras?.ragCollections,
1443
+ ragRegistry: parts.ragRegistry,
1444
+ mcpClients: parts.mcpClients,
1445
+ requestLogger: parts.logger,
1446
+ plugins: extras?.plugins,
1447
+ workerRegistry,
1448
+ applyServerExtras: extras?.applyServerExtras ?? false,
1449
+ };
1450
+ }
1451
+ /**
1452
+ * Assemble an IServerPipelineContext from `this` for a given scope (startup =
1453
+ * global; session = per-session). Builds the FRESH per-session worker registry
1454
+ * ONCE and threads it both to the `workerRegistry` field (read by the DAG
1455
+ * plugin) and to `createAgentBuilder` (so the agent wires the same workers).
1456
+ *
1457
+ * `logLlmCall` is sourced from `scope.parts.logger` — the per-session
1458
+ * SessionRequestLogger, which implements IRequestLogger.logLlmCall — so token
1459
+ * accounting is no longer a no-op (closes the Task-6 `_requestLogger` gap).
1460
+ */
1461
+ async buildServerCtx(scope) {
1462
+ const workerRegistry = await this.buildWorkerRegistry(scope.parts);
1463
+ const extras = {
1464
+ applyServerExtras: scope.applyServerExtras ?? false,
1465
+ plugins: scope.plugins,
1466
+ historyRag: scope.historyRag,
1467
+ ragCollections: scope.ragCollections,
1468
+ };
1469
+ // Per-session request logger (SessionRequestLogger) — the live sink for
1470
+ // logLlmCall. Falls back to the server-level _requestLogger if ever unset.
1471
+ const requestLogger = scope.parts.logger ?? this._requestLogger;
1472
+ // Durable knowledge backend is built unconditionally in start()
1473
+ // (buildKnowledgeBackend); guard idempotently so the ctx field is always
1474
+ // populated even if buildServerCtx is ever reached before start() finishes.
1475
+ this.buildKnowledgeBackend();
1476
+ return createServerPipelineContext({
1477
+ resolveLlm: (role) => this.resolveRoleLlm(role),
1478
+ knowledgeRagFor: (sid) => this.knowledgeRagFor(sid),
1479
+ // Durable backend + resolved embedder shared with every pipeline; the
1480
+ // controller pipeline consumes both (session-bundle persistence +
1481
+ // target-state semantic distance).
1482
+ stepperKnowledgeBackend: this._stepperKnowledgeBackend ?? new InMemoryKnowledgeBackend(),
1483
+ embedder: this._resolvedEmbedder,
1484
+ // External tools are NOT carried on this build-time ctx: definitions arrive
1485
+ // per-REQUEST (HTTP body.tools) and the controller routes them per-request
1486
+ // via PipelineContext.externalTools inside the coordinator handler.
1487
+ toolsRag: this._toolsRagHandle, // undefined → EMPTY_TOOLS_RAG via factory
1488
+ ragRegistry: scope.parts.ragRegistry,
1489
+ callMcp: (n, a, s) => this.callMcp(n, a, s),
1490
+ mcpClients: scope.parts.mcpClients,
1491
+ subagents: (this.cfg.subAgentConfigs ?? []).map((s) => ({
1492
+ name: s.name,
1493
+ description: s.description,
1494
+ })),
1495
+ mintStepperId: () => this._mintStepperId(),
1496
+ mintTurnId: () => this._mintTurnId(),
1497
+ logger: this._fileLogger,
1498
+ logLlmCall: (e) => requestLogger?.logLlmCall?.(e),
1499
+ createAgentBuilder: () => this.buildBaseBuilder(this.partsToBaseInput(scope.parts, workerRegistry, extras)),
1500
+ makeLlm: (c) => this._makeLlm(c),
1501
+ llmMap: this._llmMap,
1502
+ pipelineFallback: this._pipelineFallback,
1503
+ mainLlm: this._mainLlm,
1504
+ helperLlm: this._helperLlm,
1505
+ mainTemp: this._mainTemp ?? 0.7,
1506
+ workerRegistry,
1507
+ warn: (m) => this.warn(m),
1508
+ });
1509
+ }
1510
+ /**
1511
+ * Assemble a SmartAgentBuilder wired with all shared infra EXCEPT the
1512
+ * coordinator — the part shared by the startup path and buildSessionAgent.
1513
+ * The caller wires the coordinator variant AFTER this returns. Each call site
1514
+ * supplies its own scope's values (startup = global; session = session-scoped);
1515
+ * every `.withXxx` is applied conditionally on its `parts` field so both work.
1516
+ *
1517
+ * `applyServerExtras` gates the startup-only, config/plugin-derived wiring
1518
+ * (circuit breaker, reranker/queryExpander/outputValidator, skill manager,
1519
+ * LLM-call & tool-selection strategies, client adapters, and the YAML `mcp:`
1520
+ * connect path). The per-session re-wire omits these (it inherits a slimmer
1521
+ * agent) so they stay gated to preserve behavior.
1522
+ */
1523
+ async buildBaseBuilder(parts) {
1524
+ let builder = new SmartAgentBuilder({
1525
+ // F1: the YAML `mcp:` block is connected ONCE up-front (in
1526
+ // buildSharedPipelineInfra) and injected here via `withMcpClients` below.
1527
+ // Only pass `mcp:` to the builder constructor as a LAST-RESORT auto-connect
1528
+ // path when NO pre-connected clients are supplied — otherwise omit it so
1529
+ // build() cannot open a second connection. (build() already short-circuits
1530
+ // on `this._mcpClients`; dropping the key is belt-and-suspenders.)
1531
+ ...(parts.applyServerExtras && !parts.mcpClients
1532
+ ? { mcp: this.cfg.mcp }
1533
+ : {}),
1428
1534
  agent: this.cfg.agent,
1429
1535
  prompts: this.cfg.prompts,
1430
1536
  skipModelValidation: this.cfg.skipModelValidation,
1431
1537
  })
1432
- .withMainLlm(this._mainLlm)
1433
- .withClassifierLlm(this._classifierLlm)
1434
- .withLogger(this._fileLogger)
1435
- .withMode(this.cfg.mode ?? 'smart')
1436
- .withMcpClients(parts.mcpClients) // skips connect + re-vectorize
1437
- .setRagRegistry(parts.ragRegistry)
1438
- .withRequestLogger(parts.logger); // per-session token-logger
1439
- if (this._helperLlm)
1440
- b = b.withHelperLlm(this._helperLlm);
1441
- if (parts.toolsRag)
1442
- b = b.setToolsRag(parts.toolsRag);
1443
- // FRESH per-session workers — re-wire the registry from the SAME subagent
1444
- // configs the primary build() used, injecting globals + this session's
1445
- // logger + the CACHED per-worker LLM/embedder. No LLM reconstruction.
1446
- if (this.cfg.subAgentConfigs && this.cfg.subAgentConfigs.length > 0) {
1447
- const registry = new Map();
1448
- for (const sub of this.cfg.subAgentConfigs) {
1449
- // Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
1450
- // clears `_workerLlmCache`, the next buildSessionAgent used to throw
1451
- // "worker LLM set not cached" because the cache was assumed
1452
- // pre-populated by the primary build(). buildSubAgent itself routes
1453
- // through `resolveWorkerLlmSet` which is build-on-miss, so calling
1454
- // it without an `injected` arg rebuilds the cache entry. We then
1455
- // re-read the entry to honour the per-worker slot priority below.
1456
- if (!this._workerLlmCache.has(sub.name)) {
1457
- await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
1458
- }
1459
- const cached = this._workerLlmCache.get(sub.name);
1460
- if (!cached) {
1461
- // Defence in depth — should be impossible after the lazy build
1462
- // above unless buildSubAgent's contract changes.
1463
- throw new Error(`worker LLM set not cached for '${sub.name}'`);
1538
+ .withMainLlm(parts.mainLlm)
1539
+ .withClassifierLlm(parts.classifierLlm)
1540
+ .withLogger(parts.fileLogger)
1541
+ .withMode(this.cfg.mode ?? 'smart');
1542
+ if (parts.helperLlm) {
1543
+ builder = builder.withHelperLlm(parts.helperLlm);
1544
+ }
1545
+ if (parts.toolsRag) {
1546
+ builder = builder.setToolsRag(parts.toolsRag);
1547
+ }
1548
+ if (parts.historyRag) {
1549
+ builder = builder.setHistoryRag(parts.historyRag);
1550
+ }
1551
+ for (const collection of parts.ragCollections ?? []) {
1552
+ builder = builder.addRagCollection(collection);
1553
+ }
1554
+ if (parts.ragRegistry) {
1555
+ builder = builder.setRagRegistry(parts.ragRegistry);
1556
+ }
1557
+ if (parts.requestLogger) {
1558
+ builder = builder.withRequestLogger(parts.requestLogger);
1559
+ }
1560
+ if (parts.applyServerExtras) {
1561
+ const plugins = parts.plugins;
1562
+ if (this.cfg.circuitBreaker) {
1563
+ builder = builder.withCircuitBreaker(this.cfg.circuitBreaker);
1564
+ }
1565
+ if (plugins?.reranker) {
1566
+ builder = builder.withReranker(plugins.reranker);
1567
+ }
1568
+ if (plugins?.queryExpander) {
1569
+ builder = builder.withQueryExpander(plugins.queryExpander);
1570
+ }
1571
+ if (plugins?.outputValidator) {
1572
+ builder = builder.withOutputValidator(plugins.outputValidator);
1573
+ }
1574
+ // Skill manager (DI > YAML config > plugin)
1575
+ const skillManager = this.cfg.skillManager ??
1576
+ plugins?.skillManager ??
1577
+ resolveSkillManager(this.cfg.skills);
1578
+ if (skillManager) {
1579
+ builder = builder.withSkillManager(skillManager);
1580
+ }
1581
+ // LLM call strategy (from agent config)
1582
+ const strategyName = this.cfg.agent?.llmCallStrategy;
1583
+ if (strategyName) {
1584
+ const { StreamingLlmCallStrategy, NonStreamingLlmCallStrategy, FallbackLlmCallStrategy, } = await import('@mcp-abap-adt/llm-agent');
1585
+ const strategies = {
1586
+ streaming: () => new StreamingLlmCallStrategy(),
1587
+ 'non-streaming': () => new NonStreamingLlmCallStrategy(),
1588
+ fallback: () => new FallbackLlmCallStrategy(parts.fileLogger),
1589
+ };
1590
+ const factory = strategies[strategyName];
1591
+ if (factory) {
1592
+ builder = builder.withLlmCallStrategy(factory());
1464
1593
  }
1465
- // Per-worker injected slot priority (review HIGH #7):
1466
- // worker-cached (from the primary build, includes backfilled
1467
- // subCfg.mcp / subCfg.rag results) → parent's session-scoped
1468
- // fallback. Encoded HERE so buildSubAgent does not need to know
1469
- // the difference; it just consumes injected.mcpClients/toolsRag.
1470
- const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
1471
- ? cached.mcpClients
1472
- : parts.mcpClients;
1473
- const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
1474
- const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
1475
- ragRegistry: parts.ragRegistry,
1476
- toolsRag: injectedToolsRag,
1477
- mcpClients: injectedMcpClients,
1478
- requestLogger: parts.logger,
1479
- mainLlm: cached.mainLlm,
1480
- classifierLlm: cached.classifierLlm,
1481
- helperLlm: cached.helperLlm,
1482
- embedder: cached.embedder,
1483
- });
1484
- registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
1485
- description: sub.description,
1486
- }));
1487
1594
  }
1488
- b = b.withSubAgents(registry);
1489
- if (this._stepperCoordinatorHandler) {
1490
- // Stepper coordinator is stateless — reuse the same handler instance
1491
- // across sessions (session context arrives via ctx.sessionId).
1492
- b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
1595
+ // Tool-selection strategy (from agent.toolSelection config)
1596
+ const toolSelectionCfg = this.cfg.agent?.toolSelection;
1597
+ if (toolSelectionCfg?.strategy) {
1598
+ builder = builder.withToolSelectionStrategy(resolveToolSelectionStrategy(toolSelectionCfg.strategy, {
1599
+ minScore: toolSelectionCfg.minScore,
1600
+ }));
1493
1601
  }
1494
- else if (this._dagCoordinatorTemplate) {
1495
- const tpl = this._dagCoordinatorTemplate;
1496
- const workers = new Map([...registry].filter(([name]) => name !== tpl.oracleName));
1497
- const raw = tpl.oracleName ? registry.get(tpl.oracleName) : undefined;
1498
- b = b.withDagCoordinator({
1499
- ...tpl.deps,
1500
- workers,
1501
- stateOracle: raw ? new SubAgentStateOracle(raw) : undefined,
1502
- });
1602
+ }
1603
+ if (parts.mcpClients) {
1604
+ builder = builder.withMcpClients(parts.mcpClients);
1605
+ }
1606
+ if (parts.applyServerExtras) {
1607
+ // Client adapters (DI > plugin; ClineClientAdapter is the default).
1608
+ const { ClineClientAdapter } = await import('@mcp-abap-adt/llm-agent');
1609
+ const adapterSources = [
1610
+ ...(this.cfg.clientAdapters ?? []),
1611
+ ...(parts.plugins?.clientAdapters ?? []),
1612
+ new ClineClientAdapter(),
1613
+ ];
1614
+ for (const adapter of adapterSources) {
1615
+ builder = builder.withClientAdapter(adapter);
1503
1616
  }
1504
1617
  }
1505
- else if (this._stepperCoordinatorHandler) {
1506
- // No sub-agent configs but stepper coordinator configured — wire it directly.
1507
- // (Stepper coordinator is stateless and works without sub-agents.)
1508
- b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
1618
+ if (parts.workerRegistry.size > 0) {
1619
+ builder = builder.withSubAgents(parts.workerRegistry);
1509
1620
  }
1510
- const handle = await b.build();
1511
- return handle.agent;
1621
+ return builder;
1622
+ }
1623
+ /**
1624
+ * Builds the per-session SmartAgent by routing through the selected pipeline
1625
+ * plugin (`buildPipelineInstance`). The pipeline owns coordinator wiring; the
1626
+ * session-scoped pipeline context (`buildServerCtx`) supplies the FRESH
1627
+ * per-session worker registry + session logger + the global
1628
+ * ragRegistry/toolsRag/mcpClients + the CACHED per-worker LLM/embedder
1629
+ * (this._workerLlmCache) via `createAgentBuilder`. It NEVER reuses the primary
1630
+ * build()'s global registry/coordinator and NEVER constructs new LLM clients.
1631
+ *
1632
+ * The pipeline returns `{ agent, close }`; we register `close` under the
1633
+ * sessionId so the lifecycle `onDispose` hook frees per-session pipeline
1634
+ * resources on eviction / shutdown / reconfigure.
1635
+ */
1636
+ async buildSessionAgent(parts) {
1637
+ // Guard: globals must already be captured by the primary build() before any
1638
+ // session graph is built (the registry calls this lazily on first acquire).
1639
+ if (!this._mainLlm || !this._classifierLlm || !this._fileLogger) {
1640
+ throw new Error('buildSessionAgent invoked before primary build() captured globals');
1641
+ }
1642
+ const inst = await this.buildPipelineInstance({
1643
+ sessionId: parts.sessionId,
1644
+ parts,
1645
+ });
1646
+ // Register the pipeline's disposal hook keyed by sessionId. A prior
1647
+ // instance for the same sessionId (e.g. invalidateAll rebuild) is closed
1648
+ // first so its resources never leak.
1649
+ const prior = this._sessionCloseFns.get(parts.sessionId);
1650
+ if (prior) {
1651
+ this._sessionCloseFns.delete(parts.sessionId);
1652
+ try {
1653
+ await prior();
1654
+ }
1655
+ catch {
1656
+ // Best-effort: a stale close failure must not block the new build.
1657
+ }
1658
+ }
1659
+ this._sessionCloseFns.set(parts.sessionId, () => inst.close());
1660
+ // IPipelineInstance.agent is typed as ISmartAgent; the built-in plugins
1661
+ // return the concrete SmartAgent from SmartAgentBuilder.build().
1662
+ return inst.agent;
1512
1663
  }
1513
1664
  /**
1514
1665
  * Resolve identity (mint cookie when needed), acquire the per-session graph,
@@ -1807,6 +1958,13 @@ export class SmartServer {
1807
1958
  }
1808
1959
  throw err;
1809
1960
  }
1961
+ // #171 (review#8): the adapter has already normalized Anthropic
1962
+ // tool_use/tool_result blocks into the OpenAI-shaped Message[]
1963
+ // (assistant.tool_calls + role:'tool' with tool_call_id). Run the same
1964
+ // external-results extraction the OpenAI path uses so Anthropic clients get
1965
+ // identical stateless-resume behaviour: consumed external turns are stripped
1966
+ // and their results threaded to the agent keyed by deterministic `ext:` id.
1967
+ const { results: externalResults, sanitizedMessages } = buildExternalResults(normalized.messages);
1810
1968
  const augmentedOptions = session
1811
1969
  ? {
1812
1970
  ...normalized.options,
@@ -1814,15 +1972,16 @@ export class SmartServer {
1814
1972
  trace: { traceId: session.traceId },
1815
1973
  toolAvailability: session.graph.toolAvailability,
1816
1974
  pendingToolResults: session.graph.pendingToolResults,
1975
+ externalResults,
1817
1976
  }
1818
- : normalized.options;
1977
+ : { ...normalized.options, externalResults };
1819
1978
  if (normalized.stream) {
1820
1979
  res.writeHead(200, {
1821
1980
  'Content-Type': 'text/event-stream',
1822
1981
  'Cache-Control': 'no-cache',
1823
1982
  Connection: 'keep-alive',
1824
1983
  });
1825
- for await (const event of adapter.transformStream(agent.streamProcess(normalized.messages, augmentedOptions), normalized.context)) {
1984
+ for await (const event of adapter.transformStream(agent.streamProcess(sanitizedMessages, augmentedOptions), normalized.context)) {
1826
1985
  const eventLine = event.event ? `event: ${event.event}\n` : '';
1827
1986
  res.write(`${eventLine}data: ${event.data}\n\n`);
1828
1987
  }
@@ -1830,7 +1989,7 @@ export class SmartServer {
1830
1989
  return;
1831
1990
  }
1832
1991
  // Non-streaming
1833
- const result = await agent.process(normalized.messages, augmentedOptions);
1992
+ const result = await agent.process(sanitizedMessages, augmentedOptions);
1834
1993
  res.setHeader('Content-Type', 'application/json');
1835
1994
  if (!result.ok) {
1836
1995
  res.writeHead(500);
@@ -1987,6 +2146,13 @@ export class SmartServer {
1987
2146
  return normalizedMessage;
1988
2147
  })
1989
2148
  .filter((m) => m !== null);
2149
+ // #171 (review#11): consume external (client-executed) tool result turns
2150
+ // from the incoming history into a validated `extId → result` map and strip
2151
+ // those raw turns from the messages forwarded to the agent (so no internal
2152
+ // LLM call ever sees an unmatched assistant tool_calls). On a normal request
2153
+ // with no external history this returns the messages unchanged + an empty
2154
+ // map — a safe no-op. The map is threaded via options.externalResults.
2155
+ const { results: externalResults, sanitizedMessages } = buildExternalResults(normalizedMessages);
1990
2156
  const invalidToolsHeader = externalToolsValidation.errors.length > 0
1991
2157
  ? {
1992
2158
  'x-smartagent-invalid-tools': String(externalToolsValidation.errors.length),
@@ -2001,7 +2167,10 @@ export class SmartServer {
2001
2167
  });
2002
2168
  const id = `chatcmpl-${randomUUID()}`;
2003
2169
  const created = Math.floor(Date.now() / 1000);
2004
- const stream = smartAgent.streamProcess(normalizedMessages, opts);
2170
+ const stream = smartAgent.streamProcess(sanitizedMessages, {
2171
+ ...opts,
2172
+ externalResults,
2173
+ });
2005
2174
  let firstChunk = true;
2006
2175
  let finishReasonSent = false;
2007
2176
  let lastUsage = null;
@@ -2106,7 +2275,10 @@ export class SmartServer {
2106
2275
  });
2107
2276
  return;
2108
2277
  }
2109
- const result = await smartAgent.process(normalizedMessages, opts);
2278
+ const result = await smartAgent.process(sanitizedMessages, {
2279
+ ...opts,
2280
+ externalResults,
2281
+ });
2110
2282
  log({ event: 'request_done', ok: result.ok, durationMs: Date.now() - t0 });
2111
2283
  const finalContent = result.ok
2112
2284
  ? result.value.content ||