@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.
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/legacy/dag.d.ts +4 -0
- package/dist/legacy/dag.d.ts.map +1 -0
- package/dist/legacy/dag.js +4 -0
- package/dist/legacy/dag.js.map +1 -0
- package/dist/legacy/flat.d.ts +2 -0
- package/dist/legacy/flat.d.ts.map +1 -0
- package/dist/legacy/flat.js +3 -0
- package/dist/legacy/flat.js.map +1 -0
- package/dist/legacy/linear.d.ts +3 -0
- package/dist/legacy/linear.d.ts.map +1 -0
- package/dist/legacy/linear.js +3 -0
- package/dist/legacy/linear.js.map +1 -0
- package/dist/legacy/stepper.d.ts +5 -0
- package/dist/legacy/stepper.d.ts.map +1 -0
- package/dist/legacy/stepper.js +5 -0
- package/dist/legacy/stepper.js.map +1 -0
- package/dist/pipelines/controller.d.ts +19 -0
- package/dist/pipelines/controller.d.ts.map +1 -0
- package/dist/pipelines/controller.js +100 -0
- package/dist/pipelines/controller.js.map +1 -0
- package/dist/pipelines/dag.d.ts +23 -0
- package/dist/pipelines/dag.d.ts.map +1 -0
- package/dist/pipelines/dag.js +46 -0
- package/dist/pipelines/dag.js.map +1 -0
- package/dist/pipelines/flat.d.ts +14 -0
- package/dist/pipelines/flat.d.ts.map +1 -0
- package/dist/pipelines/flat.js +18 -0
- package/dist/pipelines/flat.js.map +1 -0
- package/dist/pipelines/linear.d.ts +17 -0
- package/dist/pipelines/linear.d.ts.map +1 -0
- package/dist/pipelines/linear.js +26 -0
- package/dist/pipelines/linear.js.map +1 -0
- package/dist/pipelines/parsers.d.ts +5 -0
- package/dist/pipelines/parsers.d.ts.map +1 -0
- package/dist/pipelines/parsers.js +20 -0
- package/dist/pipelines/parsers.js.map +1 -0
- package/dist/pipelines/server-context.d.ts +53 -0
- package/dist/pipelines/server-context.d.ts.map +1 -0
- package/dist/pipelines/server-context.js +9 -0
- package/dist/pipelines/server-context.js.map +1 -0
- package/dist/pipelines/stepper.d.ts +23 -0
- package/dist/pipelines/stepper.d.ts.map +1 -0
- package/dist/pipelines/stepper.js +60 -0
- package/dist/pipelines/stepper.js.map +1 -0
- package/dist/smart-agent/config.d.ts +13 -4
- package/dist/smart-agent/config.d.ts.map +1 -1
- package/dist/smart-agent/config.js +137 -228
- package/dist/smart-agent/config.js.map +1 -1
- package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +99 -0
- package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -0
- package/dist/smart-agent/controller/controller-coordinator-handler.js +627 -0
- package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -0
- package/dist/smart-agent/controller/memorizer.d.ts +6 -0
- package/dist/smart-agent/controller/memorizer.d.ts.map +1 -0
- package/dist/smart-agent/controller/memorizer.js +5 -0
- package/dist/smart-agent/controller/memorizer.js.map +1 -0
- package/dist/smart-agent/controller/need-resolver.d.ts +3 -0
- package/dist/smart-agent/controller/need-resolver.d.ts.map +1 -0
- package/dist/smart-agent/controller/need-resolver.js +4 -0
- package/dist/smart-agent/controller/need-resolver.js.map +1 -0
- package/dist/smart-agent/controller/planner.d.ts +34 -0
- package/dist/smart-agent/controller/planner.d.ts.map +1 -0
- package/dist/smart-agent/controller/planner.js +247 -0
- package/dist/smart-agent/controller/planner.js.map +1 -0
- package/dist/smart-agent/controller/prompts.d.ts +17 -0
- package/dist/smart-agent/controller/prompts.d.ts.map +1 -0
- package/dist/smart-agent/controller/prompts.js +20 -0
- package/dist/smart-agent/controller/prompts.js.map +1 -0
- package/dist/smart-agent/controller/session-bundle.d.ts +15 -0
- package/dist/smart-agent/controller/session-bundle.d.ts.map +1 -0
- package/dist/smart-agent/controller/session-bundle.js +53 -0
- package/dist/smart-agent/controller/session-bundle.js.map +1 -0
- package/dist/smart-agent/controller/subagent-client.d.ts +7 -0
- package/dist/smart-agent/controller/subagent-client.d.ts.map +1 -0
- package/dist/smart-agent/controller/subagent-client.js +18 -0
- package/dist/smart-agent/controller/subagent-client.js.map +1 -0
- package/dist/smart-agent/controller/target-state.d.ts +28 -0
- package/dist/smart-agent/controller/target-state.d.ts.map +1 -0
- package/dist/smart-agent/controller/target-state.js +59 -0
- package/dist/smart-agent/controller/target-state.js.map +1 -0
- package/dist/smart-agent/controller/types.d.ts +121 -0
- package/dist/smart-agent/controller/types.d.ts.map +1 -0
- package/dist/smart-agent/controller/types.js +2 -0
- package/dist/smart-agent/controller/types.js.map +1 -0
- package/dist/smart-agent/resolve-agent-embedder.d.ts.map +1 -1
- package/dist/smart-agent/resolve-agent-embedder.js +8 -2
- package/dist/smart-agent/resolve-agent-embedder.js.map +1 -1
- package/dist/smart-agent/smart-server.d.ts +188 -31
- package/dist/smart-agent/smart-server.d.ts.map +1 -1
- package/dist/smart-agent/smart-server.js +809 -637
- package/dist/smart-agent/smart-server.js.map +1 -1
- package/dist/smart-agent/stepper-coordinator-handler.d.ts.map +1 -1
- package/dist/smart-agent/stepper-coordinator-handler.js +25 -11
- package/dist/smart-agent/stepper-coordinator-handler.js.map +1 -1
- 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 {
|
|
7
|
-
import {
|
|
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
|
|
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 {
|
|
71
|
-
import {
|
|
72
|
-
import {
|
|
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
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
463
|
-
*
|
|
464
|
-
* `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
520
|
-
const mainLlm =
|
|
521
|
-
? await makeLlm(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
:
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
//
|
|
617
|
-
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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(
|
|
1291
|
-
const classifierTemp = Number(
|
|
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: () =>
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
:
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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:
|
|
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
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
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
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
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(
|
|
1433
|
-
.withClassifierLlm(
|
|
1434
|
-
.withLogger(
|
|
1435
|
-
.withMode(this.cfg.mode ?? 'smart')
|
|
1436
|
-
|
|
1437
|
-
.
|
|
1438
|
-
|
|
1439
|
-
if (
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
1506
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 ||
|