@mcp-abap-adt/llm-agent-server-libs 20.0.0 → 20.4.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 (166) hide show
  1. package/dist/builders/controller-skill-pipeline-builder.d.ts +57 -0
  2. package/dist/builders/controller-skill-pipeline-builder.d.ts.map +1 -0
  3. package/dist/builders/controller-skill-pipeline-builder.js +175 -0
  4. package/dist/builders/controller-skill-pipeline-builder.js.map +1 -0
  5. package/dist/generated/version.d.ts +1 -1
  6. package/dist/generated/version.js +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/pipelines/controller.d.ts.map +1 -1
  12. package/dist/pipelines/controller.js +19 -3
  13. package/dist/pipelines/controller.js.map +1 -1
  14. package/dist/pipelines/coordinator-resolvers.d.ts +68 -0
  15. package/dist/pipelines/coordinator-resolvers.d.ts.map +1 -0
  16. package/dist/pipelines/coordinator-resolvers.js +97 -0
  17. package/dist/pipelines/coordinator-resolvers.js.map +1 -0
  18. package/dist/pipelines/parsers.d.ts +1 -1
  19. package/dist/pipelines/parsers.d.ts.map +1 -1
  20. package/dist/pipelines/parsers.js +5 -4
  21. package/dist/pipelines/parsers.js.map +1 -1
  22. package/dist/pipelines/register-skill-sources.d.ts.map +1 -1
  23. package/dist/pipelines/register-skill-sources.js +5 -0
  24. package/dist/pipelines/register-skill-sources.js.map +1 -1
  25. package/dist/smart-agent/build-stepper-root.d.ts +3 -2
  26. package/dist/smart-agent/build-stepper-root.d.ts.map +1 -1
  27. package/dist/smart-agent/build-stepper-root.js +2 -1
  28. package/dist/smart-agent/build-stepper-root.js.map +1 -1
  29. package/dist/smart-agent/config-reload-watcher.d.ts +30 -0
  30. package/dist/smart-agent/config-reload-watcher.d.ts.map +1 -0
  31. package/dist/smart-agent/config-reload-watcher.js +101 -0
  32. package/dist/smart-agent/config-reload-watcher.js.map +1 -0
  33. package/dist/smart-agent/config-validator.d.ts +21 -0
  34. package/dist/smart-agent/config-validator.d.ts.map +1 -0
  35. package/dist/smart-agent/config-validator.js +196 -0
  36. package/dist/smart-agent/config-validator.js.map +1 -0
  37. package/dist/smart-agent/config.d.ts +16 -254
  38. package/dist/smart-agent/config.d.ts.map +1 -1
  39. package/dist/smart-agent/config.js +17 -904
  40. package/dist/smart-agent/config.js.map +1 -1
  41. package/dist/smart-agent/controller/board.d.ts +6 -3
  42. package/dist/smart-agent/controller/board.d.ts.map +1 -1
  43. package/dist/smart-agent/controller/board.js +21 -0
  44. package/dist/smart-agent/controller/board.js.map +1 -1
  45. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +6 -48
  46. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -1
  47. package/dist/smart-agent/controller/controller-coordinator-handler.js +68 -357
  48. package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -1
  49. package/dist/smart-agent/controller/finalizer.d.ts +5 -0
  50. package/dist/smart-agent/controller/finalizer.d.ts.map +1 -1
  51. package/dist/smart-agent/controller/finalizer.js +12 -5
  52. package/dist/smart-agent/controller/finalizer.js.map +1 -1
  53. package/dist/smart-agent/controller/parser.d.ts +11 -0
  54. package/dist/smart-agent/controller/parser.d.ts.map +1 -0
  55. package/dist/smart-agent/controller/parser.js +77 -0
  56. package/dist/smart-agent/controller/parser.js.map +1 -0
  57. package/dist/smart-agent/controller/planner.js +1 -1
  58. package/dist/smart-agent/controller/planner.js.map +1 -1
  59. package/dist/smart-agent/controller/recall.d.ts +48 -0
  60. package/dist/smart-agent/controller/recall.d.ts.map +1 -0
  61. package/dist/smart-agent/controller/recall.js +196 -0
  62. package/dist/smart-agent/controller/recall.js.map +1 -0
  63. package/dist/smart-agent/controller/reviewer.d.ts.map +1 -1
  64. package/dist/smart-agent/controller/reviewer.js +1 -1
  65. package/dist/smart-agent/controller/reviewer.js.map +1 -1
  66. package/dist/smart-agent/controller/usage-logging.d.ts +16 -0
  67. package/dist/smart-agent/controller/usage-logging.d.ts.map +1 -0
  68. package/dist/smart-agent/controller/usage-logging.js +39 -0
  69. package/dist/smart-agent/controller/usage-logging.js.map +1 -0
  70. package/dist/smart-agent/http/adapter-route-handler.d.ts +13 -0
  71. package/dist/smart-agent/http/adapter-route-handler.d.ts.map +1 -0
  72. package/dist/smart-agent/http/adapter-route-handler.js +76 -0
  73. package/dist/smart-agent/http/adapter-route-handler.js.map +1 -0
  74. package/dist/smart-agent/http/chat-route-handler.d.ts +15 -0
  75. package/dist/smart-agent/http/chat-route-handler.d.ts.map +1 -0
  76. package/dist/smart-agent/http/chat-route-handler.js +327 -0
  77. package/dist/smart-agent/http/chat-route-handler.js.map +1 -0
  78. package/dist/smart-agent/http/config-route-handler.d.ts +21 -0
  79. package/dist/smart-agent/http/config-route-handler.d.ts.map +1 -0
  80. package/dist/smart-agent/http/config-route-handler.js +149 -0
  81. package/dist/smart-agent/http/config-route-handler.js.map +1 -0
  82. package/dist/smart-agent/http/health-route-handler.d.ts +12 -0
  83. package/dist/smart-agent/http/health-route-handler.d.ts.map +1 -0
  84. package/dist/smart-agent/http/health-route-handler.js +19 -0
  85. package/dist/smart-agent/http/health-route-handler.js.map +1 -0
  86. package/dist/smart-agent/http/models-route-handler.d.ts +17 -0
  87. package/dist/smart-agent/http/models-route-handler.d.ts.map +1 -0
  88. package/dist/smart-agent/http/models-route-handler.js +65 -0
  89. package/dist/smart-agent/http/models-route-handler.js.map +1 -0
  90. package/dist/smart-agent/http/response-helpers.d.ts +31 -0
  91. package/dist/smart-agent/http/response-helpers.d.ts.map +1 -0
  92. package/dist/smart-agent/http/response-helpers.js +59 -0
  93. package/dist/smart-agent/http/response-helpers.js.map +1 -0
  94. package/dist/smart-agent/http/route-table.d.ts +44 -0
  95. package/dist/smart-agent/http/route-table.d.ts.map +1 -0
  96. package/dist/smart-agent/http/route-table.js +35 -0
  97. package/dist/smart-agent/http/route-table.js.map +1 -0
  98. package/dist/smart-agent/http/session-cookie.d.ts +10 -0
  99. package/dist/smart-agent/http/session-cookie.d.ts.map +1 -0
  100. package/dist/smart-agent/http/session-cookie.js +16 -0
  101. package/dist/smart-agent/http/session-cookie.js.map +1 -0
  102. package/dist/smart-agent/http/sessions-route-handler.d.ts +8 -0
  103. package/dist/smart-agent/http/sessions-route-handler.d.ts.map +1 -0
  104. package/dist/smart-agent/http/sessions-route-handler.js +54 -0
  105. package/dist/smart-agent/http/sessions-route-handler.js.map +1 -0
  106. package/dist/smart-agent/http/usage-route-handler.d.ts +12 -0
  107. package/dist/smart-agent/http/usage-route-handler.d.ts.map +1 -0
  108. package/dist/smart-agent/http/usage-route-handler.js +28 -0
  109. package/dist/smart-agent/http/usage-route-handler.js.map +1 -0
  110. package/dist/smart-agent/knowledge/make-knowledge-backend.d.ts +13 -0
  111. package/dist/smart-agent/knowledge/make-knowledge-backend.d.ts.map +1 -0
  112. package/dist/smart-agent/knowledge/make-knowledge-backend.js +18 -0
  113. package/dist/smart-agent/knowledge/make-knowledge-backend.js.map +1 -0
  114. package/dist/smart-agent/llm/role-llm-resolver.d.ts +30 -0
  115. package/dist/smart-agent/llm/role-llm-resolver.d.ts.map +1 -0
  116. package/dist/smart-agent/llm/role-llm-resolver.js +44 -0
  117. package/dist/smart-agent/llm/role-llm-resolver.js.map +1 -0
  118. package/dist/smart-agent/llm-config-map.d.ts +38 -0
  119. package/dist/smart-agent/llm-config-map.d.ts.map +1 -0
  120. package/dist/smart-agent/llm-config-map.js +75 -0
  121. package/dist/smart-agent/llm-config-map.js.map +1 -0
  122. package/dist/smart-agent/mcp-readiness-monitor.d.ts +38 -0
  123. package/dist/smart-agent/mcp-readiness-monitor.d.ts.map +1 -0
  124. package/dist/smart-agent/mcp-readiness-monitor.js +81 -0
  125. package/dist/smart-agent/mcp-readiness-monitor.js.map +1 -0
  126. package/dist/smart-agent/mcp-readiness-registry.d.ts +37 -0
  127. package/dist/smart-agent/mcp-readiness-registry.d.ts.map +1 -0
  128. package/dist/smart-agent/mcp-readiness-registry.js +55 -0
  129. package/dist/smart-agent/mcp-readiness-registry.js.map +1 -0
  130. package/dist/smart-agent/resolve-config-sections.d.ts +15 -0
  131. package/dist/smart-agent/resolve-config-sections.d.ts.map +1 -0
  132. package/dist/smart-agent/resolve-config-sections.js +212 -0
  133. package/dist/smart-agent/resolve-config-sections.js.map +1 -0
  134. package/dist/smart-agent/session-lifecycle/index.d.ts +117 -0
  135. package/dist/smart-agent/session-lifecycle/index.d.ts.map +1 -0
  136. package/dist/smart-agent/session-lifecycle/index.js +152 -0
  137. package/dist/smart-agent/session-lifecycle/index.js.map +1 -0
  138. package/dist/smart-agent/skill-plugins-config.d.ts +9 -1
  139. package/dist/smart-agent/skill-plugins-config.d.ts.map +1 -1
  140. package/dist/smart-agent/skill-plugins-config.js +27 -1
  141. package/dist/smart-agent/skill-plugins-config.js.map +1 -1
  142. package/dist/smart-agent/skill-plugins-host-factory.d.ts +14 -2
  143. package/dist/smart-agent/skill-plugins-host-factory.d.ts.map +1 -1
  144. package/dist/smart-agent/skill-plugins-host-factory.js +16 -4
  145. package/dist/smart-agent/skill-plugins-host-factory.js.map +1 -1
  146. package/dist/smart-agent/smart-server.d.ts +151 -222
  147. package/dist/smart-agent/smart-server.d.ts.map +1 -1
  148. package/dist/smart-agent/smart-server.js +527 -1384
  149. package/dist/smart-agent/smart-server.js.map +1 -1
  150. package/dist/smart-agent/stepper-config.d.ts +138 -0
  151. package/dist/smart-agent/stepper-config.d.ts.map +1 -0
  152. package/dist/smart-agent/stepper-config.js +216 -0
  153. package/dist/smart-agent/stepper-config.js.map +1 -0
  154. package/dist/smart-agent/tools-rag-handle.d.ts +10 -0
  155. package/dist/smart-agent/tools-rag-handle.d.ts.map +1 -0
  156. package/dist/smart-agent/tools-rag-handle.js +76 -0
  157. package/dist/smart-agent/tools-rag-handle.js.map +1 -0
  158. package/dist/smart-agent/workers/worker-registry.d.ts +159 -0
  159. package/dist/smart-agent/workers/worker-registry.d.ts.map +1 -0
  160. package/dist/smart-agent/workers/worker-registry.js +203 -0
  161. package/dist/smart-agent/workers/worker-registry.js.map +1 -0
  162. package/dist/smart-agent/yaml-loader.d.ts +7 -0
  163. package/dist/smart-agent/yaml-loader.d.ts.map +1 -0
  164. package/dist/smart-agent/yaml-loader.js +146 -0
  165. package/dist/smart-agent/yaml-loader.js.map +1 -0
  166. package/package.json +7 -7
@@ -6,46 +6,28 @@ import http from 'node:http';
6
6
  import { createRequire } from 'node:module';
7
7
  import { resolve as pathResolve } from 'node:path';
8
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';
11
- import { MCPClientWrapper, McpClientAdapter, } from '@mcp-abap-adt/llm-agent-mcp';
9
+ import { isReadinessReporter, } from '@mcp-abap-adt/llm-agent';
10
+ import { ClaudeSkillManager, CodexSkillManager, FileSystemPluginLoader, FileSystemSkillManager, getDefaultPluginDirs, HealthChecker, InMemoryKnowledgeBackend, KnowledgeRag, makeLlm, mergePluginExports, SessionRequestLogger, SmartAgentBuilder, SmartAgentSubAgent, } from '@mcp-abap-adt/llm-agent-libs';
11
+ import { DefaultMcpFailureClassifier, MCPClientWrapper, McpClientAdapter, } from '@mcp-abap-adt/llm-agent-mcp';
12
12
  import { makeRag, prefetchEmbedderFactories, resolveEmbedder, } from '@mcp-abap-adt/llm-agent-rag';
13
13
  import { PACKAGE_VERSION } from '../generated/version.js';
14
+ import { ConfigReloadWatcher } from './config-reload-watcher.js';
15
+ import { handleAdapterRequest } from './http/adapter-route-handler.js';
16
+ import { handleChat } from './http/chat-route-handler.js';
17
+ import { handleConfigUpdate, } from './http/config-route-handler.js';
18
+ import { handleHealthRoute } from './http/health-route-handler.js';
19
+ import { handleEmbeddingModelsList, handleModelsList, } from './http/models-route-handler.js';
20
+ import { CORS_HEADERS, jsonError, writeNotReady, } from './http/response-helpers.js';
21
+ import { HttpRouteTable } from './http/route-table.js';
22
+ import { handleSessionDelete, handleSessionResume, handleSessionsList, } from './http/sessions-route-handler.js';
23
+ import { handleUsageRoute } from './http/usage-route-handler.js';
24
+ import { makeDefaultRoleLlm, RoleLlmResolver, } from './llm/role-llm-resolver.js';
14
25
  import { resolveAgentEmbedder } from './resolve-agent-embedder.js';
15
- import { resolveSessionIdentity } from './session-identity-resolver.js';
26
+ import { makeToolsRagHandle } from './tools-rag-handle.js';
27
+ export { writeNotReady } from './http/response-helpers.js';
16
28
  // ---------------------------------------------------------------------------
17
29
  // Helpers
18
30
  // ---------------------------------------------------------------------------
19
- function mapStopReason(r) {
20
- if (r === 'stop')
21
- return 'stop';
22
- if (r === 'tool_calls')
23
- return 'tool_calls';
24
- return 'length';
25
- }
26
- function jsonError(message, type, code) {
27
- return JSON.stringify({
28
- error: { message, type, ...(code ? { code } : {}) },
29
- });
30
- }
31
- function jsonValidationError(message, code, param) {
32
- return JSON.stringify({
33
- error: {
34
- message,
35
- type: 'invalid_request_error',
36
- code,
37
- param,
38
- },
39
- });
40
- }
41
- function readBody(req) {
42
- return new Promise((resolve, reject) => {
43
- const chunks = [];
44
- req.on('data', (c) => chunks.push(c));
45
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
46
- req.on('error', reject);
47
- });
48
- }
49
31
  function resolveSkillManager(cfg) {
50
32
  if (!cfg)
51
33
  return undefined;
@@ -62,11 +44,6 @@ function resolveSkillManager(cfg) {
62
44
  return undefined;
63
45
  }
64
46
  }
65
- const CORS_HEADERS = {
66
- 'Access-Control-Allow-Origin': '*',
67
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
68
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
69
- };
70
47
  // ---------------------------------------------------------------------------
71
48
  // SmartServer
72
49
  // ---------------------------------------------------------------------------
@@ -77,8 +54,7 @@ import { LinearPipelinePlugin } from '../pipelines/linear.js';
77
54
  import { createServerPipelineContext, } from '../pipelines/server-context.js';
78
55
  import { StepperPipelinePlugin } from '../pipelines/stepper.js';
79
56
  import { normalizeLlmConfig, resolveLlmConfig, resolveLlmConfigStrict, resolveToolSelectionStrategy, } from './config.js';
80
- import { makeKnowledgeSemanticIndex } from './embedder-knowledge-index.js';
81
- import { JsonlKnowledgeBackend } from './jsonl-knowledge-backend.js';
57
+ import { makeKnowledgeBackend } from './knowledge/make-knowledge-backend.js';
82
58
  import { makePgPool, makePgReadPool } from './pg-pool.js';
83
59
  import { InMemorySessionMetaStore } from './session-meta-store.js';
84
60
  import { buildSkillHostFromConfig, initSkillHost, } from './skill-plugins-host-factory.js';
@@ -86,267 +62,18 @@ export { generateConfigTemplate, loadYamlConfig, resolveCoordinatorActivation, r
86
62
  export { makePgPool, makePgReadPool } from './pg-pool.js';
87
63
  export { parseSkillPluginsConfig, } from './skill-plugins-config.js';
88
64
  export { buildSkillHostFromConfig, initSkillHost, validateServedGroups, } from './skill-plugins-host-factory.js';
89
- /**
90
- * Drain every cached worker's `close` (if any), then clear the cache map.
91
- * Used by config-reload (PUT /v1/config + hot-reload — Fix #14/18/21) and by
92
- * server `close()` to release per-worker MCP connections that were attached
93
- * to the discarded `SmartAgentHandle`s.
94
- *
95
- * IMPORTANT — in-flight caveat: this aborts any request that is mid-call on
96
- * a worker's MCP client. That is acceptable for an admin action (config
97
- * reload, server shutdown) where the alternative is leaking connections.
98
- * Server `close()` calls this AFTER `lifecycle.disposeAll()` so per-session
99
- * graphs that reference worker clients are torn down first.
100
- *
101
- * Uses `Promise.allSettled` so one failing close cannot block the others.
102
- */
103
- export async function drainWorkerCache(cache) {
104
- const closers = [];
105
- for (const entry of cache.values()) {
106
- if (entry.close) {
107
- try {
108
- closers.push(entry.close());
109
- }
110
- catch {
111
- // sync throw (defensive — close is async by contract)
112
- }
113
- }
114
- }
115
- cache.clear();
116
- if (closers.length > 0) {
117
- await Promise.allSettled(closers);
118
- }
119
- }
120
- /**
121
- * Build-once-per-worker resolver. The first time a worker name is seen, it
122
- * constructs the worker's main/classifier/(optional helper) LLM + embedder and
123
- * caches the set; every later call (e.g. each per-session worker re-wire)
124
- * returns the SAME set by reference — never reconstructing LLM clients
125
- * (locked invariant: LLM/embedder clients are global, built once).
126
- *
127
- * Accepts optional `makeToolsRag`/`makeHistoryRag`/`makeMcpClients` factories;
128
- * when provided, the resolver builds them ONCE on the first miss and caches
129
- * them on the returned set. Subsequent calls return the cached resources by
130
- * reference — never re-vectorizing or re-connecting MCP.
131
- */
132
- export async function resolveWorkerLlmSet(input) {
133
- const hit = input.cache.get(input.name);
134
- if (hit)
135
- return hit;
136
- const mainLlm = await input.makeMain();
137
- const classifierLlm = await input.makeClassifier();
138
- const helperLlm = input.makeHelper ? await input.makeHelper() : undefined;
139
- const embedder = input.makeEmbedder ? await input.makeEmbedder() : undefined;
140
- const toolsRag = input.makeToolsRag ? await input.makeToolsRag() : undefined;
141
- const historyRag = input.makeHistoryRag
142
- ? await input.makeHistoryRag()
143
- : undefined;
144
- const mcpClients = input.makeMcpClients
145
- ? await input.makeMcpClients()
146
- : undefined;
147
- const set = {
148
- mainLlm,
149
- classifierLlm,
150
- helperLlm,
151
- embedder,
152
- toolsRag,
153
- historyRag,
154
- mcpClients,
155
- };
156
- input.cache.set(input.name, set);
157
- return set;
158
- }
159
- /**
160
- * Backfill the per-worker cache entry from the BUILT handle (review HIGH #7).
161
- *
162
- * The primary `buildSubAgent` populates `cached.mcpClients`/`toolsRag`/
163
- * `historyRag` only when the worker config provided DI factories. Workers
164
- * configured with `subCfg.mcp: ...` (regular config that triggers the
165
- * builder's own auto-connect) or with `subCfg.rag: ...` whose RAG is owned
166
- * by the builder leave those slots empty — so per-session re-wires would
167
- * fall back to the PARENT's MCP/RAG, losing the worker's own connection.
168
- *
169
- * After the builder finishes, this helper captures what the handle actually
170
- * holds and stores it BY REFERENCE on the cache entry. Subsequent per-session
171
- * re-wires read the same slots and find the worker's own resources.
172
- *
173
- * Pure helper, mutates `entry` in place. No-op when the corresponding slot
174
- * is already populated (DI path wins) or when the handle has no resource for
175
- * that slot (worker simply didn't declare one).
176
- */
177
- export async function backfillWorkerCacheFromHandle(entry, handle) {
178
- if ((!entry.mcpClients || entry.mcpClients.length === 0) &&
179
- handle.mcpClients &&
180
- handle.mcpClients.length > 0) {
181
- entry.mcpClients = handle.mcpClients;
182
- }
183
- if (!entry.toolsRag) {
184
- const t = handle.ragRegistry.get('tools');
185
- if (t)
186
- entry.toolsRag = t;
187
- }
188
- if (!entry.historyRag) {
189
- const h = handle.ragRegistry.get('history');
190
- if (h)
191
- entry.historyRag = h;
192
- }
193
- // Capture the per-worker shutdown function (Fix #21). If the entry already
194
- // had a close from a previous build (e.g. the same worker name was rebuilt
195
- // WITHOUT going through `drainWorkerCache` first — defence in depth), await
196
- // the prior close before overwriting so its MCP connections do not leak.
197
- if (handle.close) {
198
- if (entry.close) {
199
- try {
200
- await entry.close();
201
- }
202
- catch {
203
- // Best-effort; never block the new build on a stale close failure.
204
- }
205
- }
206
- entry.close = handle.close;
207
- }
208
- }
209
- /**
210
- * Share the parent RAG registry with subagents (per-session worker re-wire).
211
- * Session/user/global collections written at the top level become visible to
212
- * workers; the per-call scope filter (`rag-query.ts`) isolates by
213
- * `ctx.sessionId` / `ctx.options.userId`. A worker's own declared store is
214
- * registered INTO this same registry under its namespace. When the parent
215
- * registry is undefined (no top-level registry yet — e.g. unit test seam),
216
- * return undefined so the builder allocates its own SimpleRagRegistry.
217
- */
218
- export function resolveSubAgentRagRegistry(input) {
219
- return input.parentRagRegistry;
220
- }
221
- /**
222
- * Composes the cookie identity resolver + SessionGraphFactory + SessionRegistry
223
- * into one lifecycle object the server's `_handle` consumes. The default MCP
224
- * factory returns the shared GLOBAL clients by reference (one upstream
225
- * connection); a creds-aware build swaps it out (out of scope here).
226
- */
227
- export function buildSessionLifecycle(opts) {
228
- const factory = new SessionGraphFactory({
229
- mcpClientFactory: (_identity) => opts.mcpClients,
230
- toolsRag: opts.toolsRag,
231
- ragRegistry: opts.ragRegistry,
232
- buildAgent: opts.buildAgent,
233
- logger: opts.logger,
234
- onDispose: opts.onDispose,
235
- });
236
- const registry = new SessionRegistry({
237
- idleTtlMs: opts.idleTtlMs,
238
- maxSessions: opts.maxSessions,
239
- factory,
240
- });
241
- return {
242
- resolve: (cookieHeader, isHttps) => resolveSessionIdentity({
243
- cookieHeader,
244
- cookieName: opts.cookieName,
245
- maxAgeSeconds: Math.max(1, Math.floor(opts.idleTtlMs / 1000)),
246
- isHttps,
247
- }),
248
- acquire: (sessionId) => registry.acquire(sessionId),
249
- release: (sessionId, graph) => registry.release(sessionId, graph),
250
- evictIdle: () => registry.evictIdle(),
251
- disposeAll: () => registry.disposeAll(),
252
- invalidateAll: () => registry.invalidateAll(),
253
- registry,
254
- };
255
- }
256
- /**
257
- * Seed session-scope guidance entries into a BRAND-NEW session's knowledge-RAG
258
- * (deployment-supplied tool-usage guidance the planner/executor read in "Known
259
- * facts"). Idempotent: rehydrates via init() and writes ONLY when the session is
260
- * empty (`fingerprint() === 'n=0'`), so resumes never duplicate. Entries are
261
- * config DATA — the runtime stays MCP-agnostic (no tool knowledge in agent code).
262
- */
263
- export async function seedSessionKnowledge(kr, seeds, nowIso) {
264
- if (seeds.length === 0)
265
- return;
266
- await kr.init?.();
267
- if (kr.fingerprint?.() !== 'n=0')
268
- return; // not a brand-new session → skip
269
- for (const s of seeds) {
270
- await kr.write({
271
- content: s.content,
272
- metadata: {
273
- traceId: 'seed',
274
- turnId: 'seed',
275
- stepperId: 'seed',
276
- task: 'session-seed',
277
- artifactType: s.artifactType,
278
- createdAt: nowIso,
279
- },
280
- });
281
- }
282
- }
283
- /**
284
- * Record that a request for `sessionId` STARTED — create the meta row on first
285
- * sight, else touch it and mark in-progress. Called from the live request path
286
- * (`_withSession`) so GET /v1/sessions, resume and delete actually see sessions
287
- * produced by normal chat/stream traffic (review Finding 3). `userIdentity` is
288
- * the sessionId itself in the default no-auth build — matching how the
289
- * /v1/sessions endpoints resolve identity (`resolved.identity.sessionId`).
290
- */
291
- export async function recordSessionStart(store, sessionId, nowIso) {
292
- const existing = await store.get(sessionId);
293
- if (!existing) {
294
- await store.create({
295
- sessionId,
296
- userIdentity: sessionId,
297
- createdAt: nowIso,
298
- lastUsedAt: nowIso,
299
- status: 'in-progress',
300
- });
301
- return;
302
- }
303
- await store.touch(sessionId, nowIso);
304
- await store.setStatus(sessionId, 'in-progress');
305
- }
306
- /**
307
- * Record that a request for `sessionId` FINISHED — touch + mark idle (so it can
308
- * be resumed). No-op if the row was deleted mid-flight.
309
- */
310
- export async function recordSessionEnd(store, sessionId, nowIso) {
311
- const existing = await store.get(sessionId);
312
- if (!existing)
313
- return;
314
- await store.touch(sessionId, nowIso);
315
- await store.setStatus(sessionId, 'idle');
316
- }
317
- /**
318
- * List all sessions for a given user identity.
319
- * Extracted for unit-testability (mirrors the /v1/usage handler pattern).
320
- */
321
- export async function handleListSessions(store, identity) {
322
- const sessions = await store.listForUser(identity);
323
- return { sessions };
324
- }
325
- /**
326
- * Resume (claim) a session by ID for a user identity.
327
- * Sets the session status to 'idle' so it can be re-entered.
328
- */
329
- export async function handleResumeSession(store, identity, id) {
330
- const row = await store.get(id);
331
- if (!row || row.userIdentity !== identity) {
332
- return { ok: false, error: 'session not found' };
333
- }
334
- await store.setStatus(id, 'idle');
335
- const updated = await store.get(id);
336
- return { ok: true, session: updated };
337
- }
338
- /**
339
- * Delete a session by ID for a user identity, and evict its RAG state.
340
- */
341
- export async function handleDeleteSession(store, identity, id, evictFn) {
342
- const row = await store.get(id);
343
- if (!row || row.userIdentity !== identity) {
344
- return { ok: false, error: 'session not found' };
345
- }
346
- await store.delete(id);
347
- await evictFn(id);
348
- return { ok: true };
349
- }
65
+ // ---------------------------------------------------------------------------
66
+ // Worker-LLM cache + RAG-registry sharing (Task A7) — relocated to workers/
67
+ // ---------------------------------------------------------------------------
68
+ export { backfillWorkerCacheFromHandle, drainWorkerCache, resolveWorkerLlmSet, } from './workers/worker-registry.js';
69
+ import { backfillWorkerCacheFromHandle, resolveWorkerLlmSet, WorkerRegistry, } from './workers/worker-registry.js';
70
+ // ---------------------------------------------------------------------------
71
+ // Session-lifecycle helpers — relocated to session-lifecycle/ (R3)
72
+ // Internal callers (SmartServer methods) import the value symbols they call;
73
+ // re-export the full public surface for the package barrel.
74
+ // ---------------------------------------------------------------------------
75
+ import { buildSessionLifecycle, recordSessionEnd, recordSessionStart, resolveSubAgentRagRegistry, seedSessionKnowledge, } from './session-lifecycle/index.js';
76
+ export { buildSessionLifecycle, handleDeleteSession, handleListSessions, handleResumeSession, recordSessionEnd, recordSessionStart, resolveSubAgentRagRegistry, seedSessionKnowledge, } from './session-lifecycle/index.js';
350
77
  // ---------------------------------------------------------------------------
351
78
  // MCP bridge for the Stepper path (B-1)
352
79
  // ---------------------------------------------------------------------------
@@ -385,6 +112,8 @@ export async function connectMcpClientsFromConfig(mcpCfg) {
385
112
  transport: 'stdio',
386
113
  command: cfg.command,
387
114
  args: cfg.args ?? [],
115
+ ...(cfg.timeout !== undefined ? { timeout: cfg.timeout } : {}),
116
+ ...(cfg.toolTimeouts ? { toolTimeouts: cfg.toolTimeouts } : {}),
388
117
  });
389
118
  }
390
119
  else {
@@ -392,6 +121,8 @@ export async function connectMcpClientsFromConfig(mcpCfg) {
392
121
  transport: 'auto',
393
122
  url: cfg.url,
394
123
  headers: cfg.headers,
124
+ ...(cfg.timeout !== undefined ? { timeout: cfg.timeout } : {}),
125
+ ...(cfg.toolTimeouts ? { toolTimeouts: cfg.toolTimeouts } : {}),
395
126
  });
396
127
  }
397
128
  await wrapper.connect();
@@ -399,20 +130,32 @@ export async function connectMcpClientsFromConfig(mcpCfg) {
399
130
  }
400
131
  return connected;
401
132
  }
402
- export function buildMcpBridge(clients) {
133
+ export function buildMcpBridge(clients, classifier = new DefaultMcpFailureClassifier()) {
403
134
  return async (name, args, _signal) => {
404
135
  const safeArgs = args != null && typeof args === 'object' && !Array.isArray(args)
405
136
  ? args
406
137
  : {};
407
138
  for (const client of clients) {
139
+ const probe = client.healthCheck
140
+ ? () => client.healthCheck().then((r) => (r.ok ? r.value : false))
141
+ : undefined;
408
142
  const listed = await client.listTools();
409
- if (!listed.ok)
143
+ if (!listed.ok) {
144
+ // FAIL LOUD on an availability failure: a transient listTools() outage must
145
+ // NOT make the tool look merely absent (→ "Tool not found"/tool-blind). A
146
+ // benign error (this client genuinely can't list) falls through to the next.
147
+ if ((await classifier.classify(listed.error, probe)) === 'unavailable')
148
+ throw listed.error;
410
149
  continue;
150
+ }
411
151
  const owns = listed.value.some((t) => t.name === name);
412
152
  if (!owns)
413
153
  continue;
414
154
  const result = await client.callTool(name, safeArgs);
415
155
  if (!result.ok) {
156
+ // Availability failure → fail loud; a tool-level error → LLM feedback text.
157
+ if ((await classifier.classify(result.error, probe)) === 'unavailable')
158
+ throw result.error;
416
159
  return result.error.message;
417
160
  }
418
161
  const { content } = result.value;
@@ -425,11 +168,17 @@ export class SmartServer {
425
168
  cfg;
426
169
  noop = () => { };
427
170
  /**
428
- * GLOBAL per-worker LLM/embedder cache. Populated lazily by `buildSubAgent`
171
+ * GLOBAL per-worker LLM/embedder cache registry. Populated lazily by `buildSubAgent`
429
172
  * the first time each worker name is seen; subsequent per-session re-wires
430
- * pull from this cache by reference (never reconstructing LLM clients).
173
+ * pull from the cache by reference (never reconstructing LLM clients).
174
+ * Constructed in `_buildInfra` after embedder factories are resolved.
431
175
  */
432
- _workerLlmCache = new Map();
176
+ _workers;
177
+ /**
178
+ * Declarative HTTP route table built once; `_handle` delegates to its
179
+ * `dispatch`. Replaces the former ~300-line if/else route chain.
180
+ */
181
+ _routeTable = this._buildRouteTable();
433
182
  /** Lifecycle handle wired in `start()`; consumed by `_handle`. */
434
183
  _lifecycle;
435
184
  /** Hoisted globals used by `buildSessionAgent` to re-wire fresh per-session workers. */
@@ -464,6 +213,7 @@ export class SmartServer {
464
213
  _llmMap;
465
214
  _pipelineFallback;
466
215
  _mainTemp;
216
+ _roleLlm;
467
217
  _requestLogger;
468
218
  /** ToolsRag handle built by `buildSharedPipelineInfra`; handed to every
469
219
  * pipeline's context (factory defaults to EMPTY_TOOLS_RAG if unset). */
@@ -486,6 +236,15 @@ export class SmartServer {
486
236
  * yaml). Disposed via the server's `closeFns` on shutdown.
487
237
  */
488
238
  _stepperMcpClients;
239
+ /**
240
+ * True when the consumer injected an MCP seam (`BuildAgentDeps.mcpClients` or
241
+ * `connectMcp`). In that case MCP is provisioned ONLY through the seam (the
242
+ * embeddable path must never force a real connect / builder self-connect). When
243
+ * false (default), the YAML `mcp:` path keeps the builder-owned connect so the
244
+ * builder VECTORIZES the tools into `toolsRag` (the ToolSelect ranking contract;
245
+ * see mcp-yaml-vectorization.test.ts).
246
+ */
247
+ _mcpSeamInjected;
489
248
  /**
490
249
  * The MCP clients the pipeline `callMcp` bridge dispatches over — resolved
491
250
  * UNCONDITIONALLY in `start()` as DI/plugin clients (`mcpClients`) ?? the
@@ -520,8 +279,34 @@ export class SmartServer {
520
279
  * handles owned by the plugin) are freed on eviction / shutdown / reconfigure.
521
280
  */
522
281
  _sessionCloseFns = new Map();
523
- constructor(config) {
282
+ /**
283
+ * Instance-level MCP failure classifier (DI/programmatic only — not from YAML).
284
+ * Populated from BuildAgentDeps in the constructor; defaults to DefaultMcpFailureClassifier.
285
+ * Passed to buildMcpBridge (Route B) and threaded into every pipeline ctx (Route A).
286
+ */
287
+ _mcpFailureClassifier;
288
+ /**
289
+ * Defaulted construction deps (the BuildAgentDeps DI seam). Required members
290
+ * always resolve to the real implementation when not injected; `skillHost`
291
+ * and `embedder` stay optional (present only when injected).
292
+ */
293
+ _deps;
294
+ constructor(config, deps = {}) {
524
295
  this.cfg = config;
296
+ this._mcpSeamInjected =
297
+ deps.mcpClients !== undefined || deps.connectMcp !== undefined;
298
+ this._mcpFailureClassifier =
299
+ deps.mcpFailureClassifier ?? new DefaultMcpFailureClassifier();
300
+ this._deps = {
301
+ makeLlm: deps.makeLlm ?? ((cfg) => this._makeLlmDefault(cfg)),
302
+ resolveEmbedder: deps.resolveEmbedder ?? resolveEmbedder,
303
+ prefetchEmbedderFactories: deps.prefetchEmbedderFactories ?? prefetchEmbedderFactories,
304
+ buildSkillHost: deps.buildSkillHost ?? buildSkillHostFromConfig,
305
+ connectMcp: deps.connectMcp ?? connectMcpClientsFromConfig,
306
+ ...(deps.skillHost ? { skillHost: deps.skillHost } : {}),
307
+ ...(deps.embedder ? { embedder: deps.embedder } : {}),
308
+ ...(deps.mcpClients ? { mcpClients: deps.mcpClients } : {}),
309
+ };
525
310
  }
526
311
  async start() {
527
312
  // Startup pg-pool cleanup must span the ENTIRE start(): host.load() (via
@@ -547,7 +332,17 @@ export class SmartServer {
547
332
  }
548
333
  }
549
334
  }
550
- async _start() {
335
+ /**
336
+ * Assemble and return the server INFRA bundle ONLY — every shared resource
337
+ * needed by both the HTTP `_start()` path and the embeddable
338
+ * `_buildEmbeddedAgent()` path: the infra/passthrough `smartAgent`, the
339
+ * server-only locals (`chat`/`streamChat`/`requestLogger`/etc.), the resolved
340
+ * `globalMcpClients`/`globalRagRegistry`, and the `closeFns` loop (exposed as
341
+ * `close`). It does NOT build the `'embedded'` pipeline instance — that idle
342
+ * coordinator is built only on the embeddable path (see `_buildEmbeddedAgent`),
343
+ * so a plain `start()` no longer pays for a coordinator it never serves.
344
+ */
345
+ async _buildInfra() {
551
346
  const log = this.cfg.log ?? this.noop;
552
347
  const fileLogger = {
553
348
  log: (e) => log(e),
@@ -564,23 +359,13 @@ export class SmartServer {
564
359
  const topMain = resolveLlmConfig(llmMap, 'main', pipelineFallback);
565
360
  const mainTemp = Number(topMain?.temperature ?? 0.7);
566
361
  const mainLlm = topMain
567
- ? await makeLlm({
568
- provider: topMain.provider ?? 'deepseek',
569
- apiKey: topMain.apiKey,
570
- baseURL: topMain.url,
571
- model: topMain.model,
572
- }, mainTemp)
362
+ ? await this._deps.makeLlm({ ...topMain, temperature: mainTemp })
573
363
  : (() => {
574
364
  throw new Error('no LLM configured: provide top-level llm.main');
575
365
  })();
576
366
  const classifierTemp = Number(topMain?.classifierTemperature ?? 0.1);
577
367
  const classifierLlm = topMain
578
- ? await makeLlm({
579
- provider: topMain.provider ?? 'deepseek',
580
- apiKey: topMain.apiKey,
581
- baseURL: topMain.url,
582
- model: topMain.model,
583
- }, classifierTemp)
368
+ ? await this._deps.makeLlm({ ...topMain, temperature: classifierTemp })
584
369
  : (() => {
585
370
  throw new Error('no LLM configured: provide top-level llm.main');
586
371
  })();
@@ -588,12 +373,10 @@ export class SmartServer {
588
373
  // (built only when an explicit map entry exists).
589
374
  const helperCfg = resolveLlmConfigStrict(llmMap, 'helper');
590
375
  const helperLlm = helperCfg
591
- ? await makeLlm({
592
- provider: helperCfg.provider ?? 'deepseek',
593
- apiKey: helperCfg.apiKey,
594
- baseURL: helperCfg.url,
595
- model: helperCfg.model,
596
- }, Number(helperCfg.temperature ?? 0.1))
376
+ ? await this._deps.makeLlm({
377
+ ...helperCfg,
378
+ temperature: Number(helperCfg.temperature ?? 0.1),
379
+ })
597
380
  : undefined;
598
381
  this._mainLlm = mainLlm;
599
382
  this._classifierLlm = classifierLlm;
@@ -601,6 +384,14 @@ export class SmartServer {
601
384
  this._llmMap = llmMap;
602
385
  this._pipelineFallback = pipelineFallback;
603
386
  this._mainTemp = mainTemp;
387
+ this._roleLlm = new RoleLlmResolver({
388
+ getMain: () => this._mainLlm,
389
+ getHelper: () => this._helperLlm,
390
+ getClassifier: () => this._classifierLlm,
391
+ getLlmMap: () => this._llmMap,
392
+ getPipelineFallback: () => this._pipelineFallback,
393
+ makeLlm: (lc) => this._deps.makeLlm(lc),
394
+ });
604
395
  // ---- Plugin loader -------------------------------------------------------
605
396
  const pluginLoader = this.cfg.pluginLoader ??
606
397
  (() => {
@@ -682,9 +473,19 @@ export class SmartServer {
682
473
  ...this.cfg.embedderFactories, // config takes precedence over plugins
683
474
  };
684
475
  this._mergedEmbedderFactories = mergedEmbedderFactories;
476
+ // Construct the WorkerRegistry (owns the per-worker LLM cache + build loop).
477
+ // Both _fileLogger (set at the top of _buildInfra) and _mergedEmbedderFactories
478
+ // (set above) are captured lazily via the accessor callbacks, so construction
479
+ // here precedes the first use of this._workers.
480
+ this._workers = new WorkerRegistry({
481
+ subAgentConfigs: this.cfg.subAgentConfigs,
482
+ getFileLogger: () => this._fileLogger,
483
+ getEmbedderFactories: () => this._mergedEmbedderFactories ?? {},
484
+ buildSubAgent: (name, subCfg, parentLogger, factories, injected) => this.buildSubAgent(name, subCfg, parentLogger, factories, injected),
485
+ });
685
486
  // Resolve the embedder ONCE so the same instance feeds both makeRag and the
686
487
  // subagent context-builder's toolSource (#137). See resolve-agent-embedder.
687
- const resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this.cfg.embedder, mergedEmbedderFactories);
488
+ const resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this._deps.embedder ?? this.cfg.embedder, mergedEmbedderFactories);
688
489
  // Hold the resolved embedder so buildServerCtx can thread it onto every
689
490
  // pipeline context (the controller pipeline needs it for target-state).
690
491
  this._resolvedEmbedder = resolvedEmbedder;
@@ -695,11 +496,15 @@ export class SmartServer {
695
496
  // llm-agent-rag). Absent `skillPlugins:` → no host, behaviour unchanged.
696
497
  if (this.cfg.skillPlugins) {
697
498
  const skillCfg = this.cfg.skillPlugins;
698
- const reuseAgentEmbedder = skillCfg.embedder === undefined && resolvedEmbedder !== undefined;
499
+ // An injected embedder short-circuits ALL embedder I/O for the skill host
500
+ // (no dedicated build, no prefetch) — the seam owns the embedder.
501
+ const injectedEmbedder = this._deps.embedder;
502
+ const reuseAgentEmbedder = injectedEmbedder !== undefined ||
503
+ (skillCfg.embedder === undefined && resolvedEmbedder !== undefined);
699
504
  // Prefetch the named embedder factory only when we will actually build a
700
505
  // dedicated one (the agent embedder is already prefetched + wrapped).
701
506
  if (!reuseAgentEmbedder) {
702
- await prefetchEmbedderFactories([
507
+ await this._deps.prefetchEmbedderFactories([
703
508
  skillCfg.embedder?.provider ?? 'ollama',
704
509
  ]);
705
510
  }
@@ -707,33 +512,36 @@ export class SmartServer {
707
512
  // captured pg pools are ended INSIDE initSkillHost (the later closeFns
708
513
  // cleanup never runs when start() rejects before returning a handle), so
709
514
  // the pools cannot leak open sockets on a startup failure.
710
- this._skillHost = await initSkillHost(() => buildSkillHostFromConfig(skillCfg, {
711
- resolveEmbedder: (ec) => reuseAgentEmbedder
712
- ? resolvedEmbedder
713
- : resolveEmbedder(ec, {
714
- extraFactories: mergedEmbedderFactories,
715
- }),
716
- // Real pg `Pool` provider for a `postgres` catalog (qdrant
717
- // deployment). Lazily imports `pg` and ensures the catalog table
718
- // exists on first use; pass the configured table so the DDL targets
719
- // the SAME table the catalog store reads/writes. Absent
720
- // skillPlugins.catalog.type:postgres this is never invoked.
721
- makePgPool: (connectionString) => {
722
- const pool = makePgPool(connectionString, skillCfg.catalog.type === 'postgres'
723
- ? skillCfg.catalog.table
724
- : undefined);
725
- this._skillPgPools.push(pool);
726
- return pool;
727
- },
728
- // READ-ONLY pg pool for the recall-only path — NEVER runs DDL, so a
729
- // recall-only process with read-only pg credentials does not crash
730
- // attempting to CREATE the catalog table it only reads.
731
- makePgReadPool: (connectionString) => {
732
- const pool = makePgReadPool(connectionString);
733
- this._skillPgPools.push(pool);
734
- return pool;
735
- },
736
- }), skillCfg, this._skillPgPools);
515
+ const buildHost = this._deps.skillHost
516
+ ? async () => this._deps.skillHost
517
+ : () => this._deps.buildSkillHost(skillCfg, {
518
+ resolveEmbedder: (ec) => reuseAgentEmbedder
519
+ ? (injectedEmbedder ?? resolvedEmbedder)
520
+ : this._deps.resolveEmbedder(ec, {
521
+ extraFactories: mergedEmbedderFactories,
522
+ }),
523
+ // Real pg `Pool` provider for a `postgres` catalog (qdrant
524
+ // deployment). Lazily imports `pg` and ensures the catalog table
525
+ // exists on first use; pass the configured table so the DDL targets
526
+ // the SAME table the catalog store reads/writes. Absent
527
+ // skillPlugins.catalog.type:postgres this is never invoked.
528
+ makePgPool: (connectionString) => {
529
+ const pool = makePgPool(connectionString, skillCfg.catalog.type === 'postgres'
530
+ ? skillCfg.catalog.table
531
+ : undefined);
532
+ this._skillPgPools.push(pool);
533
+ return pool;
534
+ },
535
+ // READ-ONLY pg pool for the recall-only path — NEVER runs DDL, so a
536
+ // recall-only process with read-only pg credentials does not crash
537
+ // attempting to CREATE the catalog table it only reads.
538
+ makePgReadPool: (connectionString) => {
539
+ const pool = makePgReadPool(connectionString);
540
+ this._skillPgPools.push(pool);
541
+ return pool;
542
+ },
543
+ });
544
+ this._skillHost = await initSkillHost(buildHost, skillCfg, this._skillPgPools);
737
545
  }
738
546
  // ---- RAG resolution (interface-only) ----------------------------------
739
547
  // Resolve the tools/history stores and any named collections HERE so the
@@ -758,9 +566,12 @@ export class SmartServer {
758
566
  // block above is the single source of truth for the tools/history stores.
759
567
  // Deployments that previously declared `pipeline.rag.{name}` collections must
760
568
  // move them to top-level `rag:` (or register them as plugin RAG).
761
- // MCP clients (DI > plugin > YAML). The YAML `mcp:` block is NOT pre-connected
762
- // here — see the branch below.
763
- const diOrPluginMcpClients = this.cfg.mcpClients ??
569
+ // MCP clients (BuildAgentDeps.mcpClients > DI cfg.mcpClients > plugin > YAML).
570
+ // P1b: `this._deps.mcpClients` is the embeddable seam's ready-client override —
571
+ // when present it short-circuits ALL connect paths (parallel to skillHost). The
572
+ // YAML `mcp:` block is otherwise NOT pre-connected here — see the branch below.
573
+ const diOrPluginMcpClients = this._deps.mcpClients ??
574
+ this.cfg.mcpClients ??
764
575
  (plugins.mcpClients.length > 0 ? plugins.mcpClients : undefined);
765
576
  // ---- Knowledge backend (no MCP dependency) ----------------------------
766
577
  // The remaining shared pipeline infra (`_sharedMcpClients` + the
@@ -769,48 +580,65 @@ export class SmartServer {
769
580
  // connects + vectorizes (the builder owns that single connection).
770
581
  this.buildKnowledgeBackend();
771
582
  // ---- MCP connection strategy (exactly ONE connection) -----------------
772
- // Two client sources, two orderings — both keep ONE MCP connection AND a
773
- // vectorized `toolsRag`:
583
+ // P1b: MCP is ALWAYS provisioned through the injected `BuildAgentDeps` seam so
584
+ // the embeddable `buildAgent(cfg)` path never forces a real connect. Two
585
+ // sources, ONE provisioning point:
774
586
  //
775
- // • DI/plugin clients present → inject them into the startup builder via
776
- // `withMcpClients` (builder.ts:923 short-circuits its own `cfg.mcp`
777
- // auto-connect). These pre-built clients were never vectorized by the
778
- // builder (unchanged behavior). `_sharedMcpClients` = that exact set,
779
- // and the `_toolsRagHandle` catalog is built now over them.
587
+ // • Ready clients present (`_deps.mcpClients` / `cfg.mcpClients` / plugin
588
+ // clients, captured as `diOrPluginMcpClients`) → inject them into the
589
+ // startup builder via `withMcpClients` (builder short-circuits its own
590
+ // `cfg.mcp` auto-connect). `_sharedMcpClients` = that exact set, and the
591
+ // `_toolsRagHandle` catalog is built now over them.
780
592
  //
781
- // • YAML-only (no DI/plugin) → do NOT pre-connect and do NOT inject. The
782
- // startup builder receives `cfg.mcp` (via buildBaseBuilder, since
783
- // `mcpClients` is undefined) so `build()` CONNECTS the YAML block AND
784
- // VECTORIZES the tools into `toolsRag` (the `IRag`). AFTER `build()` we
785
- // harvest its connected set into `_sharedMcpClients` so `ctx.callMcp`
786
- // and per-session agents reuse the SAME single connection, then build
787
- // the `_toolsRagHandle` catalog over them. (Restores the
788
- // tool-vectorization the inject-skip regressed, with no double-connect.)
789
- // DI precedence semantics: an explicitly-provided client set (even an EMPTY
790
- // array) overrides YAML `mcp:`. `cfg.mcpClients: []` is a deliberate "disable
791
- // MCP / override plugin+YAML" signal — it must take the DI branch (inject `[]`
792
- // → builder short-circuits via withMcpClients([]) → no YAML auto-connect), NOT
793
- // fall through to the YAML branch. So gate on presence (`!== undefined`), not
794
- // length. (`diOrPluginMcpClients` is already undefined when neither DI nor a
795
- // non-empty plugin set was provided — see its resolution above.)
796
- const hasDiOrPlugin = diOrPluginMcpClients !== undefined;
593
+ // • No ready clients but a YAML `mcp:` block → provision ONCE via the seam
594
+ // (`this._deps.connectMcp(this.cfg.mcp)`; default = real connect, an
595
+ // embedded host can inject a stub) and inject those clients too, so the
596
+ // builder does NOT self-connect from `cfg.mcp` (single provisioning
597
+ // point). The `_toolsRagHandle` catalog is built over the connected set.
598
+ // (Tool ranking falls back to the MCP catalog, same as the ready-client
599
+ // path; the builder no longer vectorizes the YAML `mcp:` block itself.)
600
+ //
601
+ // • No clients and no `mcp:` block → undefined; no MCP wiring at all.
602
+ // Precedence: a ready client set (even an EMPTY array) overrides YAML `mcp:`.
603
+ // `cfg.mcpClients: []` (or `_deps.mcpClients: []`) is a deliberate "disable
604
+ // MCP / override plugin+YAML" signal — it takes the inject branch (inject `[]`
605
+ // → builder short-circuits → no YAML connect), NOT the YAML connect branch. So
606
+ // gate on presence (`!== undefined`), not length.
607
+ const hasReadyClients = diOrPluginMcpClients !== undefined;
608
+ // YAML `mcp:` with NO ready clients AND NO injected seam → keep the legacy
609
+ // builder-owned connect so the builder VECTORIZES the tools (the ToolSelect
610
+ // ranking contract). `_sharedMcpClients` + the tools-RAG handle are harvested
611
+ // from the built handle AFTER `build()` (see the harvest block below). When
612
+ // the seam IS injected we provision through it instead (no builder connect).
613
+ const yamlBuilderConnect = !hasReadyClients && !!this.cfg.mcp && !this._mcpSeamInjected;
797
614
  let mcpClients;
798
- if (hasDiOrPlugin) {
799
- // DI/plugin branch — resolve `_sharedMcpClients` + tools-RAG handle NOW
800
- // (knowledge backend is idempotent; already built above).
801
- await this.buildSharedPipelineInfra({
802
- toolsRag,
803
- resolvedEmbedder,
804
- mcpClients: diOrPluginMcpClients,
805
- });
615
+ if (hasReadyClients) {
806
616
  mcpClients = diOrPluginMcpClients;
807
617
  }
618
+ else if (this.cfg.mcp && this._mcpSeamInjected) {
619
+ // Injected seam + YAML `mcp:` → the seam is the SINGLE provisioning point
620
+ // (the embeddable path must never force a real connect). Stash on
621
+ // `_stepperMcpClients` so the idempotent guard inside
622
+ // buildSharedPipelineInfra does not connect a second time.
623
+ this._stepperMcpClients = await this._deps.connectMcp(this.cfg.mcp);
624
+ mcpClients = this._stepperMcpClients;
625
+ }
808
626
  else {
809
- // YAML-only / no-mcp branch — let the builder connect + vectorize from
810
- // `cfg.mcp`; `_sharedMcpClients` + the tools-RAG handle are resolved from
811
- // the built handle AFTER `build()` (see below).
627
+ // No MCP, or the YAML-builder-connect path (mcpClients stays undefined so
628
+ // the builder receives `cfg.mcp` and connects + vectorizes itself).
812
629
  mcpClients = undefined;
813
630
  }
631
+ // Resolve `_sharedMcpClients` + the tools-RAG handle catalog over the
632
+ // provisioned set for every path EXCEPT yamlBuilderConnect, which resolves
633
+ // them AFTER `build()` from the harvested handle (knowledge backend is
634
+ // idempotent; already built above).
635
+ if (!yamlBuilderConnect) {
636
+ await this.buildSharedPipelineInfra({
637
+ toolsRag,
638
+ resolvedEmbedder,
639
+ mcpClients,
640
+ });
641
+ }
814
642
  // Build SubAgentRegistry from `subagents:` YAML block (if present).
815
643
  // Each sub-agent is a minimal SmartAgent reusing the parent's plugin
816
644
  // outputs (embedder factories, plugins) but with its own LLM/RAG/MCP/etc.
@@ -861,13 +689,14 @@ export class SmartServer {
861
689
  const agentHandle = await builder.build();
862
690
  const { agent: smartAgent, chat, streamChat, close: closeAgent, circuitBreakers, ragStores, modelProvider, } = agentHandle;
863
691
  const { ragRegistry: globalRagRegistry, mcpClients: globalMcpClients } = agentHandle;
864
- // ---- YAML-only MCP harvest (single-connect + restored vectorization) ----
865
- // For the YAML-only branch the startup builder connected the `mcp:` block
866
- // AND vectorized its tools into `toolsRag`. Harvest the builder's connected
867
- // set into `_sharedMcpClients` so `ctx.callMcp` and per-session agents reuse
868
- // the SAME single connection (no second connect), then build the tools-RAG
869
- // handle catalog over it. The DI/plugin branch already did this earlier.
870
- if (!hasDiOrPlugin) {
692
+ // ---- YAML-builder-connect MCP harvest (single-connect + vectorization) ----
693
+ // Only on the `yamlBuilderConnect` path (YAML `mcp:`, no ready clients, no
694
+ // injected seam) did the startup builder OWN the connection AND vectorize the
695
+ // tools into `toolsRag`. Harvest its connected set into `_sharedMcpClients` so
696
+ // `ctx.callMcp` + per-session agents reuse the SAME single connection (no
697
+ // second connect), then build the tools-RAG handle catalog over it. Every
698
+ // other path already resolved these in buildSharedPipelineInfra before build().
699
+ if (yamlBuilderConnect) {
871
700
  this._sharedMcpClients = globalMcpClients ?? [];
872
701
  await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
873
702
  }
@@ -942,7 +771,7 @@ export class SmartServer {
942
771
  // worker-owned MCP clients themselves disconnect. Ordering matters —
943
772
  // closing MCP clients while a session graph is mid-use would cut its
944
773
  // request short.
945
- await drainWorkerCache(this._workerLlmCache);
774
+ await this._workers.drain();
946
775
  });
947
776
  const startTime = Date.now();
948
777
  const healthChecker = new HealthChecker({
@@ -956,106 +785,131 @@ export class SmartServer {
956
785
  // with tool vectorization (146+ embedding calls).
957
786
  // ---- Config hot-reload (optional) ------------------------------------
958
787
  if (this.cfg.configFile) {
959
- const watcher = new ConfigWatcher(this.cfg.configFile);
960
- watcher.on('reload', (update) => {
961
- log({ event: 'config_reload', update });
962
- // Apply agent config updates
963
- const agentUpdate = {};
964
- if (update.maxIterations !== undefined)
965
- agentUpdate.maxIterations = update.maxIterations;
966
- if (update.maxToolCalls !== undefined)
967
- agentUpdate.maxToolCalls = update.maxToolCalls;
968
- if (update.ragQueryK !== undefined)
969
- agentUpdate.ragQueryK = update.ragQueryK;
970
- if (update.toolUnavailableTtlMs !== undefined)
971
- agentUpdate.toolUnavailableTtlMs = update.toolUnavailableTtlMs;
972
- if (update.showReasoning !== undefined)
973
- agentUpdate.showReasoning = update.showReasoning;
974
- if (update.historyAutoSummarizeLimit !== undefined)
975
- agentUpdate.historyAutoSummarizeLimit =
976
- update.historyAutoSummarizeLimit;
977
- if (update.prompts?.ragTranslate !== undefined)
978
- agentUpdate.ragTranslatePrompt = update.prompts.ragTranslate;
979
- if (update.prompts?.historySummary !== undefined)
980
- agentUpdate.historySummaryPrompt = update.prompts.historySummary;
981
- if (update.classificationEnabled !== undefined)
982
- agentUpdate.classificationEnabled = update.classificationEnabled;
983
- if (Object.keys(agentUpdate).length > 0) {
984
- smartAgent.applyConfigUpdate(agentUpdate);
985
- // Mirror onto `this.cfg.agent` so freshly-built session graphs
986
- // (which read `this.cfg.agent` in `buildSessionAgent`) observe the
987
- // update. Deep-merge to preserve untouched startup fields.
988
- // Note: `agentUpdate` includes flat fields ONLY whitelisted by
989
- // `AGENT_CONFIG_FIELDS` plus the two prompt fields, which we route
990
- // into `this.cfg.prompts` separately below.
991
- const agentPatch = {};
992
- for (const k of Object.keys(agentUpdate)) {
993
- if (k !== 'ragTranslatePrompt' && k !== 'historySummaryPrompt') {
994
- agentPatch[k] = agentUpdate[k];
995
- }
996
- }
788
+ const reloadWatcher = new ConfigReloadWatcher({
789
+ configFile: this.cfg.configFile,
790
+ log,
791
+ applyAgentUpdate: (u) => smartAgent.applyConfigUpdate(u),
792
+ mirrorCfg: (agentPatch, prompts) => {
997
793
  if (Object.keys(agentPatch).length > 0) {
998
- const mergedAgent = {
794
+ this.cfg.agent = {
999
795
  ...(this.cfg.agent ??
1000
796
  {}),
1001
797
  ...agentPatch,
1002
798
  };
1003
- this.cfg.agent =
1004
- mergedAgent;
1005
799
  }
1006
- if (update.prompts?.ragTranslate !== undefined ||
1007
- update.prompts?.historySummary !== undefined) {
1008
- const mergedPrompts = {
800
+ if (prompts.ragTranslate !== undefined ||
801
+ prompts.historySummary !== undefined) {
802
+ const merged = {
1009
803
  ...(this.cfg.prompts ??
1010
804
  {}),
1011
805
  };
1012
- if (update.prompts?.ragTranslate !== undefined) {
1013
- mergedPrompts.ragTranslate = update.prompts.ragTranslate;
1014
- }
1015
- if (update.prompts?.historySummary !== undefined) {
1016
- mergedPrompts.historySummary = update.prompts.historySummary;
1017
- }
806
+ if (prompts.ragTranslate !== undefined)
807
+ merged.ragTranslate = prompts.ragTranslate;
808
+ if (prompts.historySummary !== undefined)
809
+ merged.historySummary = prompts.historySummary;
1018
810
  this.cfg.prompts =
1019
- mergedPrompts;
1020
- }
1021
- }
1022
- // Per-session graphs (built by SessionGraphFactory) captured the OLD
1023
- // config and the OLD cached worker LLM set. Without invalidation,
1024
- // existing sessions keep the stale SmartAgent and a fresh acquire on a
1025
- // cookie-known sessionId still returns it. Clear the worker cache so
1026
- // the next build reads from the just-applied config, then drop every
1027
- // session graph. Failures are non-fatal — log and continue.
1028
- // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing
1029
- // the cache. Hot-reload runs from a synchronous emitter callback, so
1030
- // fire-and-forget here — same async-tolerance as the invalidateAll
1031
- // call below.
1032
- drainWorkerCache(this._workerLlmCache).catch((err) => {
1033
- log({ event: 'config_reload_drain_error', error: String(err) });
1034
- });
1035
- this._lifecycle?.invalidateAll().catch((err) => {
1036
- log({ event: 'config_reload_invalidate_error', error: String(err) });
1037
- });
1038
- // Apply RAG weight updates
1039
- if (update.vectorWeight !== undefined ||
1040
- update.keywordWeight !== undefined) {
1041
- for (const store of Object.values(ragStores)) {
1042
- if (store &&
1043
- typeof store.updateWeights === 'function') {
1044
- store.updateWeights({
1045
- vectorWeight: update.vectorWeight,
1046
- keywordWeight: update.keywordWeight,
1047
- });
1048
- }
811
+ merged;
1049
812
  }
1050
- }
1051
- });
1052
- watcher.on('error', (err) => {
1053
- log({ event: 'config_reload_error', error: String(err) });
813
+ },
814
+ drainWorkers: () => this._workers.drain(),
815
+ invalidateSessions: () => this._lifecycle?.invalidateAll() ?? Promise.resolve(),
816
+ ragStores,
1054
817
  });
1055
- watcher.start();
1056
- closeFns.push(() => watcher.stop());
818
+ reloadWatcher.start();
819
+ closeFns.push(() => reloadWatcher.stop());
1057
820
  }
1058
821
  const { requestLogger } = agentHandle;
822
+ return {
823
+ close: async () => {
824
+ for (const fn of closeFns)
825
+ await fn();
826
+ },
827
+ chat,
828
+ streamChat,
829
+ requestLogger,
830
+ // Infra/passthrough startup agent — `_start()` serves infra endpoints
831
+ // (HealthChecker / /v1/models) from this.
832
+ smartAgent,
833
+ // Resolved globals the embeddable path needs to assemble the `'embedded'`
834
+ // SessionAgentParts (the HTTP path serves per-session graphs instead).
835
+ globalMcpClients,
836
+ globalRagRegistry,
837
+ log,
838
+ healthChecker,
839
+ modelProvider,
840
+ adapterMap,
841
+ };
842
+ }
843
+ /**
844
+ * Build the embeddable COORDINATED agent for the free `buildAgent(cfg)` path.
845
+ *
846
+ * `_buildInfra().smartAgent` is the INFRA/passthrough startup agent — it has
847
+ * NO coordinator, so it would never run the configured pipeline. The HTTP
848
+ * `start()` path serves the PER-SESSION `graph.agent` (built lazily via
849
+ * buildSessionAgent → buildPipelineInstance) and keeps using `smartAgent` for
850
+ * infra endpoints (/v1/models, health). But the embeddable `buildAgent(cfg)`
851
+ * consumer has no session lifecycle, so it must receive a fully COORDINATED
852
+ * agent. Build ONE pipeline instance via the SAME path a session uses — an
853
+ * `'embedded'` session — over the shared infra, and return ITS agent. This is
854
+ * the ONLY caller that builds the embedded instance, so `start()` no longer
855
+ * pays for an idle coordinator it never serves.
856
+ *
857
+ * @internal — reached only by the same-module free `buildAgent(cfg)`; not part
858
+ * of the documented public API. Public (not `private`) solely so that seam can
859
+ * call it by name (a `private` reached only via an external cast trips
860
+ * `noUnusedLocals`).
861
+ */
862
+ async _buildEmbeddedAgent() {
863
+ const infra = await this._buildInfra();
864
+ // If the pipeline-instance build throws, the infra (LLM clients, MCP, skill
865
+ // host, pg pools) is already live — tear it down before propagating so a
866
+ // failed embedded build never leaks the infra.
867
+ let inst;
868
+ try {
869
+ inst = await this.buildPipelineInstance({
870
+ sessionId: 'embedded',
871
+ parts: this._embeddedSessionParts(infra.globalMcpClients, infra.globalRagRegistry),
872
+ });
873
+ }
874
+ catch (e) {
875
+ await infra.close().catch(() => { });
876
+ throw e;
877
+ }
878
+ return {
879
+ // PUBLIC embeddable agent = the coordinated pipeline instance's agent.
880
+ agent: inst.agent,
881
+ // Dispose the pipeline instance FIRST, then the shared infra. `finally`
882
+ // guarantees `infra.close()` runs even if `inst.close()` throws.
883
+ close: async () => {
884
+ try {
885
+ await inst.close();
886
+ }
887
+ finally {
888
+ await infra.close();
889
+ }
890
+ },
891
+ };
892
+ }
893
+ /**
894
+ * Assemble the `SessionAgentParts` for the single `'embedded'` pipeline
895
+ * instance returned by `_buildEmbeddedAgent` (the embeddable `buildAgent(cfg)`
896
+ * path).
897
+ * Mirrors EXACTLY what the session lifecycle passes to `buildSessionAgent`:
898
+ * the global mcpClients + the global ragRegistry + the global tools store,
899
+ * with a fresh per-(embedded-)session request logger.
900
+ */
901
+ _embeddedSessionParts(mcpClients, ragRegistry) {
902
+ return {
903
+ sessionId: 'embedded',
904
+ mcpClients: mcpClients ?? this._sharedMcpClients ?? [],
905
+ toolsRag: this._toolsRag,
906
+ ragRegistry,
907
+ logger: new SessionRequestLogger(),
908
+ };
909
+ }
910
+ async _start() {
911
+ const built = await this._buildInfra();
912
+ const { chat, streamChat, requestLogger, smartAgent, log, healthChecker, modelProvider, adapterMap, } = built;
1059
913
  const server = http.createServer((req, res) => this._handle(req, res, requestLogger, smartAgent, chat, streamChat, log, healthChecker, modelProvider, adapterMap).catch((err) => {
1060
914
  if (!res.headersSent) {
1061
915
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1082,8 +936,9 @@ export class SmartServer {
1082
936
  // 2. Now run lifecycle cleanup: sweep timer, lifecycle.disposeAll,
1083
937
  // config watcher stop, agent close. By this point no HTTP
1084
938
  // request is in flight, so disposing session graphs is safe.
1085
- for (const fn of closeFns)
1086
- await fn();
939
+ // `built.close()` runs the same `closeFns` loop the original
940
+ // inline close did — order preserved.
941
+ await built.close();
1087
942
  },
1088
943
  requestLogger,
1089
944
  });
@@ -1140,7 +995,7 @@ export class SmartServer {
1140
995
  const classifierTemp = Number(subFlatLlm?.classifierTemperature ?? 0.1);
1141
996
  const cached = await resolveWorkerLlmSet({
1142
997
  name,
1143
- cache: this._workerLlmCache,
998
+ cache: this._workers.cache,
1144
999
  // Preserve the existing makeLlm derivation exactly.
1145
1000
  makeMain: () => makeLlm({
1146
1001
  // ?? 'deepseek' is a TS type-narrowing net only; the config
@@ -1250,7 +1105,7 @@ export class SmartServer {
1250
1105
  // re-wires never overwrite the cache. See backfillWorkerCacheFromHandle's
1251
1106
  // doc-comment for the rationale.
1252
1107
  if (!injected) {
1253
- const entry = this._workerLlmCache.get(name);
1108
+ const entry = this._workers.cache.get(name);
1254
1109
  if (entry)
1255
1110
  await backfillWorkerCacheFromHandle(entry, handle);
1256
1111
  }
@@ -1258,32 +1113,24 @@ export class SmartServer {
1258
1113
  }
1259
1114
  // -- Pipeline-context dep sources (promoted from the inline coordinator-gate
1260
1115
  // closures; consumed by buildServerCtx, which later tasks call) ----------
1261
- /** Build an LLM from a SmartServerLlmConfig (mirrors stepperMakeLlm/DAG). */
1116
+ /** Build an LLM from a SmartServerLlmConfig (mirrors stepperMakeLlm/DAG).
1117
+ * Routes through the BuildAgentDeps seam so an injected `makeLlm` overrides
1118
+ * the real builder. */
1262
1119
  _makeLlm(lc) {
1263
- return makeLlm({
1264
- provider: lc.provider ?? 'deepseek',
1265
- apiKey: lc.apiKey,
1266
- baseURL: lc.url,
1267
- model: lc.model,
1268
- }, Number(lc.temperature ?? this._mainTemp ?? 0.7));
1120
+ return this._deps.makeLlm(lc);
1121
+ }
1122
+ /** The real `makeLlm`-backed construction (the seam's default). */
1123
+ _makeLlmDefault(lc) {
1124
+ return makeDefaultRoleLlm(lc, this._mainTemp);
1269
1125
  }
1270
1126
  /** Resolve a per-role LLM through the normalized map → pipelineFallback chain.
1271
1127
  * 'main' returns the captured mainLlm; 'helper'/'classifier' return the
1272
1128
  * prebuilt instances when present; otherwise the map/fallback config is built. */
1273
1129
  async resolveRoleLlm(role) {
1274
- if (role === 'main' && this._mainLlm)
1275
- return this._mainLlm;
1276
- if ((role === 'helper' || role === 'planner') && this._helperLlm) {
1277
- return this._helperLlm;
1130
+ if (!this._roleLlm) {
1131
+ throw new Error('resolveRoleLlm invoked before _buildInfra built the resolver');
1278
1132
  }
1279
- if (role === 'classifier' && this._classifierLlm)
1280
- return this._classifierLlm;
1281
- const cfg = resolveLlmConfig(this._llmMap, role, this._pipelineFallback);
1282
- if (cfg)
1283
- return this._makeLlm(cfg);
1284
- if (this._mainLlm)
1285
- return this._mainLlm;
1286
- throw new Error(`cannot resolve LLM for role '${role}': no config`);
1133
+ return this._roleLlm.resolve(role);
1287
1134
  }
1288
1135
  /**
1289
1136
  * Session-scoped knowledge RAG over the shared knowledge backend (built
@@ -1312,7 +1159,7 @@ export class SmartServer {
1312
1159
  }
1313
1160
  /** callMcp bridge over the shared connected MCP clients (empty when none). */
1314
1161
  callMcp(name, args, signal) {
1315
- return buildMcpBridge(this._sharedMcpClients ?? [])(name, args, signal);
1162
+ return buildMcpBridge(this._sharedMcpClients ?? [], this._mcpFailureClassifier)(name, args, signal);
1316
1163
  }
1317
1164
  _mintStepperId() {
1318
1165
  return randomUUID();
@@ -1347,7 +1194,7 @@ export class SmartServer {
1347
1194
  // connect the YAML `mcp:` block ONCE (connect is not safe to invoke twice
1348
1195
  // on the same wrapper — guard via the cache field).
1349
1196
  if (!mcpClients && !this._stepperMcpClients) {
1350
- this._stepperMcpClients = await connectMcpClientsFromConfig(this.cfg.mcp);
1197
+ this._stepperMcpClients = await this._deps.connectMcp(this.cfg.mcp);
1351
1198
  }
1352
1199
  this._sharedMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
1353
1200
  await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
@@ -1362,19 +1209,10 @@ export class SmartServer {
1362
1209
  buildKnowledgeBackend() {
1363
1210
  if (this._stepperKnowledgeBackend)
1364
1211
  return;
1365
- const logDir = this.cfg.logDir;
1366
- // Attach an embedder-backed semantic index whenever an embedder is resolved —
1367
- // for ANY pipeline. Do NOT throw here: buildKnowledgeBackend runs
1368
- // unconditionally at startup and a flat/stepper deployment without an embedder
1369
- // is valid; only the CONTROLLER mandates embedding recall, enforced at the
1370
- // ControllerFactory boundary, not globally. With an index, the controller's
1371
- // results-RAG recall ranks by meaning instead of recency.
1372
- const semantic = this._resolvedEmbedder
1373
- ? makeKnowledgeSemanticIndex(this._resolvedEmbedder)
1374
- : undefined;
1375
- this._stepperKnowledgeBackend = logDir
1376
- ? new JsonlKnowledgeBackend(logDir, semantic)
1377
- : new InMemoryKnowledgeBackend(semantic);
1212
+ this._stepperKnowledgeBackend = makeKnowledgeBackend({
1213
+ logDir: this.cfg.logDir,
1214
+ embedder: this._resolvedEmbedder,
1215
+ });
1378
1216
  }
1379
1217
  /**
1380
1218
  * Build `_toolsRagHandle` — a real IToolsRagHandle over the tools RAG store +
@@ -1388,71 +1226,7 @@ export class SmartServer {
1388
1226
  */
1389
1227
  async buildToolsRagHandle(input) {
1390
1228
  const { toolsRag, resolvedEmbedder } = input;
1391
- // Tools RAG handle over the tools store + MCP catalog.
1392
- const stepperMcpClients = this._sharedMcpClients ?? [];
1393
- let catalogCache;
1394
- const ensureCatalog = async () => {
1395
- if (catalogCache)
1396
- return catalogCache;
1397
- const catalog = new Map();
1398
- await Promise.allSettled(stepperMcpClients.map(async (client) => {
1399
- const result = await client.listTools();
1400
- if (result.ok) {
1401
- for (const t of result.value) {
1402
- if (!catalog.has(t.name))
1403
- catalog.set(t.name, t);
1404
- }
1405
- }
1406
- }));
1407
- catalogCache = catalog;
1408
- return catalog;
1409
- };
1410
- this._toolsRagHandle = {
1411
- async query(text, k, options) {
1412
- const limit = k ?? 20;
1413
- const catalog = await ensureCatalog();
1414
- if (toolsRag && resolvedEmbedder) {
1415
- // Pass options (requestLogger + trace) so the wrapped embedder logs
1416
- // this query-embedding against the request.
1417
- const embedding = new QueryEmbedding(text, resolvedEmbedder, options);
1418
- const ragResult = await toolsRag.query(embedding, limit);
1419
- if (ragResult.ok) {
1420
- const hits = [];
1421
- for (const r of ragResult.value) {
1422
- const id = r.metadata.id;
1423
- if (id?.startsWith('tool:')) {
1424
- const name = id.slice(5).replace(/:.*$/, '');
1425
- const tool = catalog.get(name);
1426
- if (tool)
1427
- hits.push(tool);
1428
- }
1429
- }
1430
- if (hits.length > 0)
1431
- return hits;
1432
- }
1433
- }
1434
- return [...catalog.values()].slice(0, limit);
1435
- },
1436
- lookup(name) {
1437
- return catalogCache?.get(name);
1438
- },
1439
- };
1440
- // F2: eagerly populate the MCP tool catalog at startup (MCP is connected
1441
- // above), so the SYNC `lookup(name)` contract (IToolsRagHandle.lookup) returns
1442
- // a tool schema BEFORE any `query()` runs. `ensureCatalog` is idempotent —
1443
- // later `query()` calls reuse the cached map. Guard against a catalog-load
1444
- // failure so startup never crashes: on failure `catalogCache` stays unset and
1445
- // `lookup` returns undefined (today's worst case), while the happy path works.
1446
- try {
1447
- await ensureCatalog();
1448
- }
1449
- catch (err) {
1450
- this.cfg.log?.({
1451
- event: 'tools_catalog_eager_load_failed',
1452
- message: 'tools catalog eager-load failed; lookup() returns undefined until first query()',
1453
- error: err instanceof Error ? err.message : String(err),
1454
- });
1455
- }
1229
+ this._toolsRagHandle = await makeToolsRagHandle(this._sharedMcpClients ?? [], toolsRag, resolvedEmbedder, this.cfg.log);
1456
1230
  }
1457
1231
  /**
1458
1232
  * Build the per-session pipeline instance from the registry. Selects the
@@ -1478,62 +1252,13 @@ export class SmartServer {
1478
1252
  /**
1479
1253
  * Build the FRESH per-session worker (sub-agent) registry from the SAME
1480
1254
  * `subagents:` configs the primary build() used, injecting globals + this
1481
- * session's logger + the CACHED per-worker LLM/embedder (this._workerLlmCache).
1255
+ * session's logger + the CACHED per-worker LLM/embedder (this._workers.cache).
1482
1256
  * NEVER reconstructs LLM clients; NEVER reuses the global registry.
1483
1257
  *
1484
- * Extracted from buildSessionAgent so both the legacy session re-wire and the
1485
- * pipeline-plugin context (`buildServerCtx` / `partsToBaseInput`) feed a real
1486
- * per-session worker map to `buildBaseBuilder` instead of an empty `new Map()`.
1258
+ * Delegates to `this._workers.build(parts)` (WorkerRegistry).
1487
1259
  */
1488
1260
  async buildWorkerRegistry(parts) {
1489
- const registry = new Map();
1490
- if (!this.cfg.subAgentConfigs || this.cfg.subAgentConfigs.length === 0) {
1491
- return registry;
1492
- }
1493
- if (!this._fileLogger) {
1494
- throw new Error('buildWorkerRegistry invoked before primary build() captured globals');
1495
- }
1496
- for (const sub of this.cfg.subAgentConfigs) {
1497
- // Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
1498
- // clears `_workerLlmCache`, the next session build used to throw
1499
- // "worker LLM set not cached" because the cache was assumed
1500
- // pre-populated by the primary build(). buildSubAgent itself routes
1501
- // through `resolveWorkerLlmSet` which is build-on-miss, so calling
1502
- // it without an `injected` arg rebuilds the cache entry. We then
1503
- // re-read the entry to honour the per-worker slot priority below.
1504
- if (!this._workerLlmCache.has(sub.name)) {
1505
- await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
1506
- }
1507
- const cached = this._workerLlmCache.get(sub.name);
1508
- if (!cached) {
1509
- // Defence in depth — should be impossible after the lazy build
1510
- // above unless buildSubAgent's contract changes.
1511
- throw new Error(`worker LLM set not cached for '${sub.name}'`);
1512
- }
1513
- // Per-worker injected slot priority (review HIGH #7):
1514
- // worker-cached (from the primary build, includes backfilled
1515
- // subCfg.mcp / subCfg.rag results) → parent's session-scoped
1516
- // fallback. Encoded HERE so buildSubAgent does not need to know
1517
- // the difference; it just consumes injected.mcpClients/toolsRag.
1518
- const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
1519
- ? cached.mcpClients
1520
- : parts.mcpClients;
1521
- const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
1522
- const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
1523
- ragRegistry: parts.ragRegistry,
1524
- toolsRag: injectedToolsRag,
1525
- mcpClients: injectedMcpClients,
1526
- requestLogger: parts.logger,
1527
- mainLlm: cached.mainLlm,
1528
- classifierLlm: cached.classifierLlm,
1529
- helperLlm: cached.helperLlm,
1530
- embedder: cached.embedder,
1531
- });
1532
- registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
1533
- description: sub.description,
1534
- }));
1535
- }
1536
- return registry;
1261
+ return this._workers.build(parts);
1537
1262
  }
1538
1263
  /**
1539
1264
  * Map SessionAgentParts → buildBaseBuilder input. `workerRegistry` is the
@@ -1622,6 +1347,7 @@ export class SmartServer {
1622
1347
  ragRegistry: scope.parts.ragRegistry,
1623
1348
  callMcp: (n, a, s) => this.callMcp(n, a, s),
1624
1349
  mcpClients: scope.parts.mcpClients,
1350
+ mcpFailureClassifier: this._mcpFailureClassifier,
1625
1351
  subagents: (this.cfg.subAgentConfigs ?? []).map((s) => ({
1626
1352
  name: s.name,
1627
1353
  description: s.description,
@@ -1752,6 +1478,8 @@ export class SmartServer {
1752
1478
  if (parts.workerRegistry.size > 0) {
1753
1479
  builder = builder.withSubAgents(parts.workerRegistry);
1754
1480
  }
1481
+ // Thread the instance-level MCP failure classifier (DI/programmatic only).
1482
+ builder = builder.withMcpFailureClassifier(this._mcpFailureClassifier);
1755
1483
  return builder;
1756
1484
  }
1757
1485
  /**
@@ -1760,7 +1488,7 @@ export class SmartServer {
1760
1488
  * session-scoped pipeline context (`buildServerCtx`) supplies the FRESH
1761
1489
  * per-session worker registry + session logger + the global
1762
1490
  * ragRegistry/toolsRag/mcpClients + the CACHED per-worker LLM/embedder
1763
- * (this._workerLlmCache) via `createAgentBuilder`. It NEVER reuses the primary
1491
+ * (this._workers.cache) via `createAgentBuilder`. It NEVER reuses the primary
1764
1492
  * build()'s global registry/coordinator and NEVER constructs new LLM clients.
1765
1493
  *
1766
1494
  * The pipeline returns `{ agent, close }`; we register `close` under the
@@ -1858,758 +1586,173 @@ export class SmartServer {
1858
1586
  url: rawUrl,
1859
1587
  normalizedPath: urlPath,
1860
1588
  });
1861
- if (req.method === 'GET' &&
1862
- (urlPath === '/v1/models' || urlPath === '/models')) {
1863
- const queryString = rawUrl.includes('?') ? rawUrl.split('?')[1] : '';
1864
- const queryParams = new URLSearchParams(queryString);
1865
- const excludeEmbedding = queryParams.get('exclude_embedding') === 'true';
1866
- let data = [
1867
- { id: 'smart-agent', object: 'model', owned_by: 'smart-agent' },
1868
- ];
1869
- if (modelProvider) {
1870
- const result = await modelProvider.getModels({ excludeEmbedding });
1871
- if (result.ok) {
1872
- data = result.value.map((m) => ({
1873
- id: m.id,
1874
- object: 'model',
1875
- owned_by: m.owned_by ?? 'unknown',
1876
- ...(m.displayName ? { display_name: m.displayName } : {}),
1877
- ...(m.provider ? { provider: m.provider } : {}),
1878
- ...(m.capabilities ? { capabilities: m.capabilities } : {}),
1879
- ...(m.contextLength ? { context_length: m.contextLength } : {}),
1880
- ...(m.streamingSupported !== undefined
1881
- ? { streaming_supported: m.streamingSupported }
1882
- : {}),
1883
- ...(m.deprecated !== undefined ? { deprecated: m.deprecated } : {}),
1884
- }));
1885
- }
1886
- }
1887
- res.writeHead(200, { 'Content-Type': 'application/json' });
1888
- res.end(JSON.stringify({ object: 'list', data }));
1889
- return;
1890
- }
1891
- if (req.method === 'GET' &&
1892
- (urlPath === '/v1/embedding-models' || urlPath === '/embedding-models')) {
1893
- let data = [];
1894
- if (modelProvider?.getEmbeddingModels) {
1895
- const result = await modelProvider.getEmbeddingModels();
1896
- if (result.ok) {
1897
- data = result.value.map((m) => ({
1898
- id: m.id,
1899
- object: 'model',
1900
- owned_by: m.owned_by ?? 'unknown',
1901
- ...(m.displayName ? { display_name: m.displayName } : {}),
1902
- ...(m.provider ? { provider: m.provider } : {}),
1903
- ...(m.capabilities ? { capabilities: m.capabilities } : {}),
1904
- ...(m.contextLength ? { context_length: m.contextLength } : {}),
1905
- ...(m.streamingSupported !== undefined
1906
- ? { streaming_supported: m.streamingSupported }
1907
- : {}),
1908
- ...(m.deprecated !== undefined ? { deprecated: m.deprecated } : {}),
1909
- }));
1910
- }
1911
- }
1912
- res.writeHead(200, { 'Content-Type': 'application/json' });
1913
- res.end(JSON.stringify({ object: 'list', data }));
1914
- return;
1915
- }
1916
- if (req.method === 'GET' && urlPath === '/v1/usage') {
1917
- const lifecycle = this._lifecycle;
1918
- if (!lifecycle) {
1919
- res.writeHead(500, { 'Content-Type': 'application/json' });
1920
- res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1921
- return;
1922
- }
1923
- const isHttps = req.socket.encrypted === true ||
1924
- req.headers['x-forwarded-proto'] === 'https';
1925
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1926
- if (resolved.minted && resolved.setCookie) {
1927
- res.setHeader('Set-Cookie', resolved.setCookie);
1928
- }
1929
- const sessionId = resolved.identity.sessionId;
1930
- const graph = await lifecycle.acquire(sessionId);
1931
- try {
1932
- res.writeHead(200, { 'Content-Type': 'application/json' });
1933
- res.end(JSON.stringify(graph.logger.getSummary()));
1934
- }
1935
- finally {
1936
- lifecycle.release(sessionId, graph);
1937
- }
1938
- return;
1939
- }
1589
+ // Server readiness: derived from the agent's MCP connection strategy (it
1590
+ // implements IReadinessReporter). No strategy / non-reporting ⇒ ready
1591
+ // (readiness unknown). Computed ONCE here and reused by /health and the
1592
+ // pre-dispatch request gate (messages/chat) via `rc.ready`.
1593
+ const ready = isReadinessReporter(smartAgent) ? smartAgent.isReady() : true;
1594
+ const rc = {
1595
+ req,
1596
+ res,
1597
+ rawUrl,
1598
+ urlPath,
1599
+ method: req.method ?? 'GET',
1600
+ ready,
1601
+ server: this,
1602
+ requestLogger,
1603
+ smartAgent,
1604
+ chat,
1605
+ streamChat,
1606
+ log,
1607
+ healthChecker,
1608
+ modelProvider,
1609
+ adapterMap,
1610
+ };
1611
+ await this._routeTable.dispatch(rc);
1612
+ }
1613
+ /**
1614
+ * Declarative route table replacing `_handle`'s if/else chain. Routes are
1615
+ * registered in the EXACT order the original chain checked them (first
1616
+ * method+path match wins), so dispatch is behaviour-identical. Each handler
1617
+ * body is the corresponding original branch moved verbatim, with `this`
1618
+ * accessed through `rc.server` and the request locals read from `rc`.
1619
+ */
1620
+ _buildRouteTable() {
1621
+ const table = new HttpRouteTable();
1622
+ table.add({
1623
+ method: 'GET',
1624
+ match: (p) => p === '/v1/models' || p === '/models',
1625
+ handle: (rc) => handleModelsList(rc),
1626
+ });
1627
+ table.add({
1628
+ method: 'GET',
1629
+ match: (p) => p === '/v1/embedding-models' || p === '/embedding-models',
1630
+ handle: (rc) => handleEmbeddingModelsList(rc),
1631
+ });
1632
+ table.add({
1633
+ method: 'GET',
1634
+ match: (p) => p === '/v1/usage',
1635
+ handle: (rc) => handleUsageRoute(rc, rc.server._lifecycle),
1636
+ });
1940
1637
  // GET /v1/sessions — list sessions for the current identity
1941
- if (req.method === 'GET' && urlPath === '/v1/sessions') {
1942
- const lifecycle = this._lifecycle;
1943
- if (!lifecycle) {
1944
- res.writeHead(500, { 'Content-Type': 'application/json' });
1945
- res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1946
- return;
1947
- }
1948
- const isHttps = req.socket.encrypted === true ||
1949
- req.headers['x-forwarded-proto'] === 'https';
1950
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1951
- if (resolved.minted && resolved.setCookie) {
1952
- res.setHeader('Set-Cookie', resolved.setCookie);
1953
- }
1954
- const identity = resolved.identity.sessionId;
1955
- const body = await handleListSessions(this._sessionMetaStore, identity);
1956
- res.writeHead(200, { 'Content-Type': 'application/json' });
1957
- res.end(JSON.stringify(body));
1958
- return;
1959
- }
1638
+ table.add({
1639
+ method: 'GET',
1640
+ match: (p) => p === '/v1/sessions',
1641
+ handle: (rc) => handleSessionsList(rc, rc.server._lifecycle, rc.server._sessionMetaStore),
1642
+ });
1960
1643
  // POST /v1/sessions/:id/resume — resume a session
1961
- {
1962
- const resumeMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)\/resume$/);
1963
- if (req.method === 'POST' && resumeMatch) {
1964
- const sessionId = resumeMatch[1];
1965
- const lifecycle = this._lifecycle;
1966
- if (!lifecycle) {
1967
- res.writeHead(500, { 'Content-Type': 'application/json' });
1968
- res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1969
- return;
1970
- }
1971
- const isHttps = req.socket.encrypted === true ||
1972
- req.headers['x-forwarded-proto'] === 'https';
1973
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1974
- if (resolved.minted && resolved.setCookie) {
1975
- res.setHeader('Set-Cookie', resolved.setCookie);
1976
- }
1977
- const identity = resolved.identity.sessionId;
1978
- const body = await handleResumeSession(this._sessionMetaStore, identity, sessionId);
1979
- const status = body.ok ? 200 : 404;
1980
- res.writeHead(status, { 'Content-Type': 'application/json' });
1981
- res.end(JSON.stringify(body));
1982
- return;
1983
- }
1984
- }
1644
+ table.add({
1645
+ method: 'POST',
1646
+ match: (p) => p.match(/^\/v1\/sessions\/([^/]+)\/resume$/) ?? false,
1647
+ handle: (rc) => handleSessionResume(rc, rc.server._lifecycle, rc.server._sessionMetaStore),
1648
+ });
1985
1649
  // DELETE /v1/sessions/:id — delete a session
1986
- {
1987
- const deleteMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)$/);
1988
- if (req.method === 'DELETE' && deleteMatch) {
1989
- const sessionId = deleteMatch[1];
1990
- const lifecycle = this._lifecycle;
1991
- if (!lifecycle) {
1992
- res.writeHead(500, { 'Content-Type': 'application/json' });
1993
- res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1650
+ table.add({
1651
+ method: 'DELETE',
1652
+ match: (p) => p.match(/^\/v1\/sessions\/([^/]+)$/) ?? false,
1653
+ handle: (rc) => handleSessionDelete(rc, rc.server._lifecycle, rc.server._sessionMetaStore, rc.server._stepperKnowledgeBackend),
1654
+ });
1655
+ // /v1/config or /config — any method (dispatches GET/PUT/405 internally)
1656
+ table.add({
1657
+ method: '*',
1658
+ match: (p) => p === '/v1/config' || p === '/config',
1659
+ handle: async (rc) => {
1660
+ if (rc.method === 'GET') {
1661
+ const models = rc.smartAgent.getActiveConfig();
1662
+ const agent = rc.smartAgent.getAgentConfig();
1663
+ const body = { models, agent };
1664
+ rc.res.writeHead(200, { 'Content-Type': 'application/json' });
1665
+ rc.res.end(JSON.stringify(body));
1994
1666
  return;
1995
1667
  }
1996
- const isHttps = req.socket.encrypted === true ||
1997
- req.headers['x-forwarded-proto'] === 'https';
1998
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1999
- if (resolved.minted && resolved.setCookie) {
2000
- res.setHeader('Set-Cookie', resolved.setCookie);
1668
+ if (rc.method === 'PUT') {
1669
+ await handleConfigUpdate(rc.req, rc.res, rc.smartAgent, this._configUpdateTarget());
1670
+ return;
2001
1671
  }
2002
- const identity = resolved.identity.sessionId;
2003
- const evictFn = async (sid) => {
2004
- // (a) Evict/dispose this session's graph from the registry.
2005
- await lifecycle.registry.evictOne(sid);
2006
- // (b) Evict the session's knowledge from the shared backend. This
2007
- // clears the long-lived in-memory backend AND removes the JSONL files
2008
- // (JsonlKnowledgeBackend.deleteSession), so a same-id re-entry never
2009
- // rehydrates stale entries — matching the README "evicts its
2010
- // knowledge-RAG entries" contract.
2011
- await this._stepperKnowledgeBackend?.deleteSession(sid);
2012
- };
2013
- const body = await handleDeleteSession(this._sessionMetaStore, identity, sessionId, evictFn);
2014
- const status = body.ok ? 200 : 404;
2015
- res.writeHead(status, { 'Content-Type': 'application/json' });
2016
- res.end(JSON.stringify(body));
2017
- return;
2018
- }
2019
- }
2020
- // /v1/config or /config
2021
- if (urlPath === '/v1/config' || urlPath === '/config') {
2022
- if (req.method === 'GET') {
2023
- const models = smartAgent.getActiveConfig();
2024
- const agent = smartAgent.getAgentConfig();
2025
- const body = { models, agent };
2026
- res.writeHead(200, { 'Content-Type': 'application/json' });
2027
- res.end(JSON.stringify(body));
2028
- return;
2029
- }
2030
- if (req.method === 'PUT') {
2031
- await this._handleConfigUpdate(req, res, smartAgent);
2032
- return;
2033
- }
2034
- // 405 for other methods
2035
- res.setHeader('Allow', 'GET, PUT, OPTIONS');
2036
- res.writeHead(405, { 'Content-Type': 'application/json' });
2037
- res.end(jsonError(`Method ${req.method} not allowed on ${urlPath}`, 'invalid_request_error'));
2038
- return;
2039
- }
2040
- if (req.method === 'GET' &&
2041
- (urlPath === '/health' || urlPath === '/v1/health')) {
2042
- const status = await healthChecker.check();
2043
- const httpCode = status.status === 'unhealthy' ? 503 : 200;
2044
- res.writeHead(httpCode, { 'Content-Type': 'application/json' });
2045
- res.end(JSON.stringify(status));
2046
- return;
2047
- }
1672
+ // 405 for other methods
1673
+ rc.res.setHeader('Allow', 'GET, PUT, OPTIONS');
1674
+ rc.res.writeHead(405, { 'Content-Type': 'application/json' });
1675
+ rc.res.end(jsonError(`Method ${rc.req.method} not allowed on ${rc.urlPath}`, 'invalid_request_error'));
1676
+ },
1677
+ });
1678
+ table.add({
1679
+ method: 'GET',
1680
+ match: (p) => p === '/health' || p === '/v1/health',
1681
+ handle: (rc) => handleHealthRoute(rc),
1682
+ });
2048
1683
  // POST /v1/messages or /messages → Anthropic adapter
2049
- if (req.method === 'POST' &&
2050
- (urlPath === '/v1/messages' || urlPath === '/messages')) {
2051
- const anthropicAdapter = adapterMap?.get('anthropic');
2052
- if (!anthropicAdapter) {
2053
- res.writeHead(404, { 'Content-Type': 'application/json' });
2054
- res.end(jsonError('Anthropic adapter not registered', 'not_found'));
2055
- return;
2056
- }
2057
- await this._withSession(req, res, async (graph, sessionId, traceId) => {
2058
- await this._handleAdapterRequest(req, res, graph.agent ?? smartAgent, anthropicAdapter, { sessionId, traceId, graph });
2059
- });
2060
- return;
2061
- }
2062
- if (req.method === 'POST' &&
2063
- (urlPath === '/v1/chat/completions' || urlPath === '/chat/completions')) {
2064
- await this._withSession(req, res, async (graph, sessionId, traceId) => {
2065
- await this._handleChat(req, res, requestLogger, graph.agent ?? smartAgent, chat, streamChat, log, modelProvider, { sessionId, traceId, graph });
2066
- });
2067
- return;
2068
- }
2069
- res.writeHead(404, { 'Content-Type': 'application/json' });
2070
- res.end(jsonError(`Cannot ${req.method} ${urlPath}`, 'invalid_request_error'));
2071
- }
2072
- async _handleAdapterRequest(req, res, agent, adapter, session) {
2073
- const raw = await readBody(req);
2074
- let body;
2075
- try {
2076
- body = JSON.parse(raw);
2077
- }
2078
- catch {
2079
- res.writeHead(400, { 'Content-Type': 'application/json' });
2080
- res.end(jsonError('Invalid JSON', 'invalid_request_error'));
2081
- return;
2082
- }
2083
- let normalized;
2084
- try {
2085
- normalized = adapter.normalizeRequest(body);
2086
- }
2087
- catch (err) {
2088
- if (err instanceof AdapterValidationError) {
2089
- res.writeHead(err.statusCode, { 'Content-Type': 'application/json' });
2090
- res.end(jsonError(err.message, 'invalid_request_error'));
2091
- return;
2092
- }
2093
- throw err;
2094
- }
2095
- // #171 (review#8): the adapter has already normalized Anthropic
2096
- // tool_use/tool_result blocks into the OpenAI-shaped Message[]
2097
- // (assistant.tool_calls + role:'tool' with tool_call_id). Run the same
2098
- // external-results extraction the OpenAI path uses so Anthropic clients get
2099
- // identical stateless-resume behaviour: consumed external turns are stripped
2100
- // and their results threaded to the agent keyed by deterministic `ext:` id.
2101
- const { results: externalResults, sanitizedMessages } = buildExternalResults(normalized.messages);
2102
- const augmentedOptions = session
2103
- ? {
2104
- ...normalized.options,
2105
- sessionId: session.sessionId,
2106
- trace: { traceId: session.traceId },
2107
- toolAvailability: session.graph.toolAvailability,
2108
- pendingToolResults: session.graph.pendingToolResults,
2109
- externalResults,
2110
- }
2111
- : { ...normalized.options, externalResults };
2112
- if (normalized.stream) {
2113
- res.writeHead(200, {
2114
- 'Content-Type': 'text/event-stream',
2115
- 'Cache-Control': 'no-cache',
2116
- Connection: 'keep-alive',
2117
- });
2118
- for await (const event of adapter.transformStream(agent.streamProcess(sanitizedMessages, augmentedOptions), normalized.context)) {
2119
- const eventLine = event.event ? `event: ${event.event}\n` : '';
2120
- res.write(`${eventLine}data: ${event.data}\n\n`);
2121
- }
2122
- res.end();
2123
- return;
2124
- }
2125
- // Non-streaming
2126
- const result = await agent.process(sanitizedMessages, augmentedOptions);
2127
- res.setHeader('Content-Type', 'application/json');
2128
- if (!result.ok) {
2129
- res.writeHead(500);
2130
- res.end(JSON.stringify(adapter.formatError?.(result.error, normalized.context) ?? {
2131
- error: {
2132
- message: result.error.message,
2133
- type: result.error.code,
2134
- },
2135
- }));
2136
- return;
2137
- }
2138
- res.writeHead(200);
2139
- res.end(JSON.stringify(adapter.formatResult(result.value, normalized.context)));
2140
- }
2141
- async _handleChat(req, res, _requestLogger, smartAgent, _chat, _streamChat, log, modelProvider, session) {
2142
- const rawBody = await readBody(req);
2143
- let parsed;
2144
- try {
2145
- parsed = JSON.parse(rawBody);
2146
- }
2147
- catch {
2148
- res.writeHead(400, { 'Content-Type': 'application/json' });
2149
- res.end(jsonError('Invalid JSON body', 'invalid_request_error'));
2150
- return;
2151
- }
2152
- if (typeof parsed !== 'object' ||
2153
- parsed === null ||
2154
- !Array.isArray(parsed.messages)) {
2155
- res.writeHead(400, { 'Content-Type': 'application/json' });
2156
- res.end(jsonError('messages must be a non-empty array', 'invalid_request_error'));
2157
- return;
2158
- }
2159
- const body = parsed;
2160
- const extractText = (c) => {
2161
- if (c === null || c === undefined)
2162
- return '';
2163
- if (typeof c === 'string')
2164
- return c;
2165
- if (!Array.isArray(c))
2166
- return '';
2167
- return c
2168
- .filter((b) => typeof b === 'object' &&
2169
- b !== null &&
2170
- b.type === 'text' &&
2171
- typeof b.text === 'string')
2172
- .map((b) => b.text)
2173
- .join('\n');
2174
- };
2175
- const userMessages = body.messages.filter((m) => m.role === 'user');
2176
- if (userMessages.length === 0) {
2177
- res.writeHead(400, { 'Content-Type': 'application/json' });
2178
- res.end(jsonError('at least one message with role "user" is required', 'invalid_request_error'));
2179
- return;
2180
- }
2181
- // Prefer the session injected by `_withSession` (cookie identity); fall
2182
- // back to the legacy x-session-id header / 'default' bucket only when no
2183
- // session was wired (defensive — production routes always inject one).
2184
- const traceId = session?.traceId ?? randomUUID();
2185
- const sessionId = session?.sessionId ??
2186
- req.headers['x-session-id'] ??
2187
- 'default';
2188
- const sessionLogger = new SessionLogger(this.cfg.logDir || null, sessionId, traceId);
2189
- const toolsValidationMode = this.cfg.agent?.externalToolsValidationMode ?? 'permissive';
2190
- const externalToolsValidation = normalizeAndValidateExternalTools(body.tools);
2191
- const externalTools = externalToolsValidation.tools;
2192
- if (externalToolsValidation.errors.length > 0) {
2193
- log({
2194
- event: 'invalid_external_tools_detected',
2195
- traceId,
2196
- sessionId,
2197
- mode: toolsValidationMode,
2198
- count: externalToolsValidation.errors.length,
2199
- errors: externalToolsValidation.errors,
2200
- });
2201
- sessionLogger.logStep('invalid_external_tools_detected', {
2202
- mode: toolsValidationMode,
2203
- count: externalToolsValidation.errors.length,
2204
- errors: externalToolsValidation.errors,
2205
- });
2206
- if (toolsValidationMode === 'strict') {
2207
- const firstError = externalToolsValidation.errors[0];
2208
- res.writeHead(400, { 'Content-Type': 'application/json' });
2209
- res.end(jsonValidationError(firstError.message, firstError.code, firstError.param));
2210
- return;
2211
- }
2212
- }
2213
- const t0 = Date.now();
2214
- log({ event: 'request_start', stream: body.stream ?? false, traceId });
2215
- const opts = {
2216
- stream: body.stream,
2217
- externalTools,
2218
- sessionId,
2219
- trace: { traceId },
2220
- sessionLogger,
2221
- model: body.model,
2222
- ...(session
2223
- ? {
2224
- toolAvailability: session.graph.toolAvailability,
2225
- pendingToolResults: session.graph.pendingToolResults,
2226
- }
2227
- : {}),
2228
- ...(body.temperature !== undefined
2229
- ? { temperature: body.temperature }
2230
- : {}),
2231
- ...(body.max_tokens !== undefined ? { maxTokens: body.max_tokens } : {}),
2232
- ...(body.top_p !== undefined ? { topP: body.top_p } : {}),
2233
- ...(body.stop !== undefined
2234
- ? { stop: Array.isArray(body.stop) ? body.stop : [body.stop] }
2235
- : {}),
2236
- };
2237
- const responseModel = body.model ?? modelProvider?.getModel() ?? 'smart-agent';
2238
- const normalizedMessages = body.messages
2239
- .map((m) => {
2240
- const role = m.role;
2241
- const normalizedMessage = {
2242
- role,
2243
- content: extractText(m.content),
2244
- };
2245
- if (role === 'tool') {
2246
- if (typeof m.tool_call_id === 'string' && m.tool_call_id.trim()) {
2247
- normalizedMessage.tool_call_id = m.tool_call_id;
2248
- }
2249
- else {
2250
- sessionLogger.logStep('drop_orphan_tool_message', {
2251
- reason: 'missing_tool_call_id',
2252
- });
2253
- return null;
2254
- }
2255
- }
2256
- if (role === 'assistant' && Array.isArray(m.tool_calls)) {
2257
- const toolCalls = m.tool_calls
2258
- .filter((tc) => typeof tc === 'object' &&
2259
- tc !== null &&
2260
- typeof tc.id === 'string' &&
2261
- tc.type === 'function' &&
2262
- typeof tc.function
2263
- ?.name === 'string' &&
2264
- typeof tc.function
2265
- ?.arguments === 'string')
2266
- .map((tc) => ({
2267
- id: tc.id,
2268
- type: 'function',
2269
- function: {
2270
- name: tc.function.name,
2271
- arguments: tc.function.arguments,
2272
- },
2273
- }));
2274
- if (toolCalls.length > 0) {
2275
- normalizedMessage.tool_calls = toolCalls;
2276
- if (!normalizedMessage.content)
2277
- normalizedMessage.content = null;
2278
- }
2279
- }
2280
- return normalizedMessage;
2281
- })
2282
- .filter((m) => m !== null);
2283
- // #171 (review#11): consume external (client-executed) tool result turns
2284
- // from the incoming history into a validated `extId → result` map and strip
2285
- // those raw turns from the messages forwarded to the agent (so no internal
2286
- // LLM call ever sees an unmatched assistant tool_calls). On a normal request
2287
- // with no external history this returns the messages unchanged + an empty
2288
- // map — a safe no-op. The map is threaded via options.externalResults.
2289
- const { results: externalResults, sanitizedMessages } = buildExternalResults(normalizedMessages);
2290
- const invalidToolsHeader = externalToolsValidation.errors.length > 0
2291
- ? {
2292
- 'x-smartagent-invalid-tools': String(externalToolsValidation.errors.length),
2293
- }
2294
- : {};
2295
- if (body.stream) {
2296
- res.writeHead(200, {
2297
- 'Content-Type': 'text/event-stream',
2298
- 'Cache-Control': 'no-cache',
2299
- Connection: 'keep-alive',
2300
- ...invalidToolsHeader,
2301
- });
2302
- const id = `chatcmpl-${randomUUID()}`;
2303
- const created = Math.floor(Date.now() / 1000);
2304
- const stream = smartAgent.streamProcess(sanitizedMessages, {
2305
- ...opts,
2306
- externalResults,
2307
- });
2308
- let firstChunk = true;
2309
- let finishReasonSent = false;
2310
- let lastUsage = null;
2311
- for await (const chunk of stream) {
2312
- if (!chunk.ok) {
2313
- const errorChunk = {
2314
- id,
2315
- object: 'chat.completion.chunk',
2316
- created,
2317
- model: responseModel,
2318
- choices: [
2319
- {
2320
- index: 0,
2321
- delta: { content: `[Error] ${chunk.error.message}` },
2322
- finish_reason: 'stop',
2323
- },
2324
- ],
2325
- };
2326
- res.write(`data: ${JSON.stringify(errorChunk)}\n\n`);
2327
- finishReasonSent = true;
2328
- break;
2329
- }
2330
- // SSE heartbeat comment — keeps connection alive, ignored by clients
2331
- if (chunk.value.heartbeat) {
2332
- const hb = chunk.value.heartbeat;
2333
- res.write(`: heartbeat tool=${hb.tool} elapsed=${hb.elapsed}ms\n\n`);
2334
- continue;
2335
- }
2336
- // SSE timing breakdown comment — sent with the final chunk
2337
- if (chunk.value.timing) {
2338
- const parts = chunk.value.timing.map((t) => `${t.phase}=${t.duration}ms`);
2339
- res.write(`: timing ${parts.join(' ')}\n\n`);
2340
- }
2341
- if (chunk.value.usage) {
2342
- lastUsage = {
2343
- prompt_tokens: chunk.value.usage.promptTokens,
2344
- completion_tokens: chunk.value.usage.completionTokens,
2345
- total_tokens: chunk.value.usage.totalTokens,
2346
- };
2347
- }
2348
- const baseResponse = {
2349
- id,
2350
- object: 'chat.completion.chunk',
2351
- created,
2352
- model: responseModel,
2353
- usage: null,
2354
- };
2355
- if (firstChunk) {
2356
- res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: { role: 'assistant', content: chunk.value.content || '' }, finish_reason: null }] })}\n\n`);
2357
- firstChunk = false;
2358
- if (!chunk.value.finishReason && !chunk.value.toolCalls)
2359
- continue;
2360
- }
2361
- if (chunk.value.content || chunk.value.toolCalls) {
2362
- const delta = {};
2363
- if (chunk.value.content)
2364
- delta.content = chunk.value.content;
2365
- if (chunk.value.toolCalls) {
2366
- delta.tool_calls = chunk.value.toolCalls.map((call, index) => {
2367
- const tc = toToolCallDelta(call, index);
2368
- return {
2369
- index: tc.index,
2370
- id: tc.id,
2371
- type: 'function',
2372
- function: {
2373
- name: tc.name,
2374
- arguments: tc.arguments || '',
2375
- },
2376
- };
2377
- });
2378
- }
2379
- res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`);
1684
+ table.add({
1685
+ method: 'POST',
1686
+ match: (p) => p === '/v1/messages' || p === '/messages',
1687
+ handle: async (rc) => {
1688
+ // Pre-dispatch readiness gate: fail loud (503) BEFORE opening any stream.
1689
+ if (!rc.ready) {
1690
+ writeNotReady(rc.res);
1691
+ return;
2380
1692
  }
2381
- if (chunk.value.finishReason) {
2382
- res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: {}, finish_reason: mapStopReason(chunk.value.finishReason) }] })}\n\n`);
2383
- finishReasonSent = true;
1693
+ const anthropicAdapter = rc.adapterMap?.get('anthropic');
1694
+ if (!anthropicAdapter) {
1695
+ rc.res.writeHead(404, { 'Content-Type': 'application/json' });
1696
+ rc.res.end(jsonError('Anthropic adapter not registered', 'not_found'));
1697
+ return;
2384
1698
  }
2385
- }
2386
- if (!finishReasonSent) {
2387
- const baseResponse = {
2388
- id,
2389
- object: 'chat.completion.chunk',
2390
- created,
2391
- model: responseModel,
2392
- usage: null,
2393
- };
2394
- res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`);
2395
- }
2396
- if ((this.cfg.reportUsage !== false ||
2397
- body.stream_options?.include_usage) &&
2398
- lastUsage) {
2399
- res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model: responseModel, choices: [], usage: lastUsage })}\n\n`);
2400
- }
2401
- res.write('data: [DONE]\n\n');
2402
- res.end();
2403
- log({
2404
- event: 'request_done',
2405
- ok: true,
2406
- stream: true,
2407
- finishReason: finishReasonSent ? 'sent' : 'fallback_stop',
2408
- durationMs: Date.now() - t0,
2409
- });
2410
- return;
2411
- }
2412
- const result = await smartAgent.process(sanitizedMessages, {
2413
- ...opts,
2414
- externalResults,
2415
- });
2416
- log({ event: 'request_done', ok: result.ok, durationMs: Date.now() - t0 });
2417
- const finalContent = result.ok
2418
- ? result.value.content ||
2419
- (result.value.toolCalls ? null : '(no response)')
2420
- : `Error: ${result.error.message}`;
2421
- const finalFinishReason = result.ok
2422
- ? mapStopReason(result.value.stopReason)
2423
- : 'stop';
2424
- let finalUsage = null;
2425
- if (result.ok && result.value.usage) {
2426
- finalUsage = {
2427
- prompt_tokens: result.value.usage.promptTokens,
2428
- completion_tokens: result.value.usage.completionTokens,
2429
- total_tokens: result.value.usage.totalTokens,
2430
- };
2431
- }
2432
- const message = {
2433
- role: 'assistant',
2434
- content: finalContent,
2435
- };
2436
- if (result.ok && result.value.toolCalls) {
2437
- message.tool_calls = result.value.toolCalls;
2438
- }
2439
- res.writeHead(200, {
2440
- 'Content-Type': 'application/json',
2441
- ...invalidToolsHeader,
1699
+ await rc.server._withSession(rc.req, rc.res, async (graph, sessionId, traceId) => {
1700
+ await handleAdapterRequest(rc.req, rc.res, graph.agent ?? rc.smartAgent, anthropicAdapter, { sessionId, traceId, graph });
1701
+ });
1702
+ },
2442
1703
  });
2443
- res.end(JSON.stringify({
2444
- id: `chatcmpl-${randomUUID()}`,
2445
- object: 'chat.completion',
2446
- created: Math.floor(Date.now() / 1000),
2447
- model: responseModel,
2448
- choices: [
2449
- {
2450
- index: 0,
2451
- message,
2452
- finish_reason: finalFinishReason,
2453
- },
2454
- ],
2455
- usage: finalUsage || {
2456
- prompt_tokens: 0,
2457
- completion_tokens: 0,
2458
- total_tokens: 0,
1704
+ table.add({
1705
+ method: 'POST',
1706
+ match: (p) => p === '/v1/chat/completions' || p === '/chat/completions',
1707
+ handle: async (rc) => {
1708
+ // Pre-dispatch readiness gate: fail loud (503) BEFORE opening any SSE stream.
1709
+ if (!rc.ready) {
1710
+ writeNotReady(rc.res);
1711
+ return;
1712
+ }
1713
+ await rc.server._withSession(rc.req, rc.res, async (graph, sessionId, traceId) => {
1714
+ await handleChat(rc.req, rc.res, rc.requestLogger, graph.agent ?? rc.smartAgent, rc.chat, rc.streamChat, rc.log, rc.modelProvider, { sessionId, traceId, graph }, this.cfg);
1715
+ });
2459
1716
  },
2460
- }));
1717
+ });
1718
+ return table;
2461
1719
  }
2462
- /** Whitelisted agent config fields allowed via PUT /v1/config. */
2463
- static AGENT_CONFIG_FIELDS = new Set([
2464
- 'maxIterations',
2465
- 'maxToolCalls',
2466
- 'ragQueryK',
2467
- 'toolUnavailableTtlMs',
2468
- 'showReasoning',
2469
- 'historyAutoSummarizeLimit',
2470
- 'classificationEnabled',
2471
- ]);
2472
- async _handleConfigUpdate(req, res, smartAgent) {
2473
- const raw = await readBody(req);
2474
- let parsed;
2475
- try {
2476
- parsed = JSON.parse(raw);
2477
- }
2478
- catch {
2479
- res.writeHead(400, { 'Content-Type': 'application/json' });
2480
- res.end(jsonError('Invalid JSON body', 'invalid_request_error'));
2481
- return;
2482
- }
2483
- if (typeof parsed !== 'object' ||
2484
- parsed === null ||
2485
- Array.isArray(parsed)) {
2486
- res.writeHead(400, { 'Content-Type': 'application/json' });
2487
- res.end(jsonError('Request body must be a JSON object', 'invalid_request_error'));
2488
- return;
2489
- }
2490
- const body = parsed;
2491
- // --- Validate agent fields against whitelist ---
2492
- if (body.agent !== undefined) {
2493
- if (typeof body.agent !== 'object' ||
2494
- body.agent === null ||
2495
- Array.isArray(body.agent)) {
2496
- res.writeHead(400, { 'Content-Type': 'application/json' });
2497
- res.end(jsonError('"agent" must be a JSON object', 'invalid_request_error'));
2498
- return;
2499
- }
2500
- const agentFields = body.agent;
2501
- const unsupported = Object.keys(agentFields).filter((k) => !SmartServer.AGENT_CONFIG_FIELDS.has(k));
2502
- if (unsupported.length > 0) {
2503
- res.writeHead(400, { 'Content-Type': 'application/json' });
2504
- res.end(jsonError(`Unsupported agent config fields: ${unsupported.join(', ')}`, 'invalid_request_error'));
2505
- return;
2506
- }
2507
- }
2508
- // --- Validate and resolve models (atomic: resolve ALL before mutating) ---
2509
- let resolvedModels;
2510
- if (body.models !== undefined) {
2511
- if (typeof body.models !== 'object' ||
2512
- body.models === null ||
2513
- Array.isArray(body.models)) {
2514
- res.writeHead(400, { 'Content-Type': 'application/json' });
2515
- res.end(jsonError('"models" must be a JSON object', 'invalid_request_error'));
2516
- return;
2517
- }
2518
- if (!this.cfg.modelResolver) {
2519
- res.writeHead(400, { 'Content-Type': 'application/json' });
2520
- res.end(jsonError('model resolver not configured', 'invalid_request_error'));
2521
- return;
2522
- }
2523
- const modelFields = body.models;
2524
- const validKeys = new Set([
2525
- 'mainModel',
2526
- 'classifierModel',
2527
- 'helperModel',
2528
- ]);
2529
- const unknownKeys = Object.keys(modelFields).filter((k) => !validKeys.has(k));
2530
- if (unknownKeys.length > 0) {
2531
- res.writeHead(400, { 'Content-Type': 'application/json' });
2532
- res.end(jsonError(`Unknown model fields: ${unknownKeys.join(', ')}`, 'invalid_request_error'));
2533
- return;
2534
- }
2535
- try {
2536
- const resolver = this.cfg.modelResolver;
2537
- const [mainLlm, classifierLlm, helperLlm] = await Promise.all([
2538
- modelFields.mainModel
2539
- ? resolver.resolve(String(modelFields.mainModel), 'main')
2540
- : undefined,
2541
- modelFields.classifierModel
2542
- ? resolver.resolve(String(modelFields.classifierModel), 'classifier')
2543
- : undefined,
2544
- modelFields.helperModel
2545
- ? resolver.resolve(String(modelFields.helperModel), 'helper')
2546
- : undefined,
2547
- ]);
2548
- resolvedModels = {};
2549
- if (mainLlm)
2550
- resolvedModels.mainLlm = mainLlm;
2551
- if (classifierLlm)
2552
- resolvedModels.classifierLlm = classifierLlm;
2553
- if (helperLlm)
2554
- resolvedModels.helperLlm = helperLlm;
2555
- }
2556
- catch (err) {
2557
- res.writeHead(500, { 'Content-Type': 'application/json' });
2558
- res.end(jsonError(String(err), 'server_error'));
2559
- return;
2560
- }
2561
- }
2562
- // --- All validation passed — apply mutations ---
2563
- if (resolvedModels) {
2564
- smartAgent.reconfigure(resolvedModels);
2565
- // Mirror onto the hoisted globals consumed by `buildSessionAgent` so
2566
- // freshly-built session graphs pick up the new LLMs by reference
2567
- // (otherwise `this._mainLlm` etc. would keep pointing at the originals
2568
- // captured during `start()`).
2569
- if (resolvedModels.mainLlm)
2570
- this._mainLlm = resolvedModels.mainLlm;
2571
- if (resolvedModels.classifierLlm)
2572
- this._classifierLlm = resolvedModels.classifierLlm;
2573
- if (resolvedModels.helperLlm)
2574
- this._helperLlm = resolvedModels.helperLlm;
2575
- }
2576
- if (body.agent) {
2577
- const patch = body.agent;
2578
- smartAgent.applyConfigUpdate(patch);
2579
- // Mirror onto `this.cfg.agent` so freshly-built session graphs (which
2580
- // read `this.cfg.agent` in `buildSessionAgent`) observe the update.
2581
- // Deep-merge to preserve untouched startup fields; replacing the whole
2582
- // `agent` block would drop YAML defaults the validator already applied.
2583
- const merged = {
2584
- ...(this.cfg.agent ?? {}),
2585
- ...patch,
2586
- };
2587
- this.cfg.agent = merged;
2588
- }
2589
- // Invalidate per-session SmartAgents + the worker-LLM cache so the next
2590
- // request mints a session graph that observes the just-applied config.
2591
- // Without this, chat routes dispatch to `graph.agent` (the per-session
2592
- // SmartAgent) which was built with the OLD config, and the PUT is a
2593
- // no-op from the consumer's perspective. Failures are non-fatal so the
2594
- // 200 response isn't blocked by a dispose hiccup.
2595
- if (resolvedModels || body.agent) {
2596
- // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing the
2597
- // cache so MCP clients owned by the discarded handles disconnect.
2598
- await drainWorkerCache(this._workerLlmCache);
2599
- try {
2600
- await this._lifecycle?.invalidateAll();
2601
- }
2602
- catch {
2603
- // Swallow: cleanup errors must not turn a successful config update
2604
- // into a 500. The next request will still get a fresh build because
2605
- // `_workerLlmCache` is already cleared and dispose is idempotent.
2606
- }
2607
- }
2608
- // --- Return updated config ---
2609
- const models = smartAgent.getActiveConfig();
2610
- const agent = smartAgent.getAgentConfig();
2611
- res.writeHead(200, { 'Content-Type': 'application/json' });
2612
- res.end(JSON.stringify({ models, agent }));
1720
+ /**
1721
+ * Build the PUT /v1/config hot-swap seam over this server's private state.
1722
+ * The setters write the SAME `_mainLlm`/`_classifierLlm`/`_helperLlm` fields
1723
+ * RoleLlmResolver's live accessors read, so the hot-swap stays observable.
1724
+ * A private object literal — NOT `implements` — so the public class shape is
1725
+ * unchanged (byte-stable public API).
1726
+ */
1727
+ _configUpdateTarget() {
1728
+ return {
1729
+ modelResolver: this.cfg.modelResolver,
1730
+ setMainLlm: (llm) => {
1731
+ this._mainLlm = llm;
1732
+ },
1733
+ setClassifierLlm: (llm) => {
1734
+ this._classifierLlm = llm;
1735
+ },
1736
+ setHelperLlm: (llm) => {
1737
+ this._helperLlm = llm;
1738
+ },
1739
+ mirrorAgentCfg: (patch) => {
1740
+ const merged = {
1741
+ ...(this.cfg.agent ?? {}),
1742
+ ...patch,
1743
+ };
1744
+ this.cfg.agent = merged;
1745
+ },
1746
+ drainWorkers: () => this._workers.drain(),
1747
+ invalidateSessions: () => this._lifecycle?.invalidateAll() ?? Promise.resolve(),
1748
+ };
2613
1749
  }
2614
1750
  }
1751
+ /** Build a runnable agent for any configured pipeline WITHOUT binding a port.
1752
+ * `SmartServer.start()` is the default impl that adds HTTP `listen` on top. */
1753
+ export async function buildAgent(cfg, deps) {
1754
+ const server = new SmartServer(cfg, deps);
1755
+ const built = await server._buildEmbeddedAgent();
1756
+ return { agent: built.agent, close: built.close };
1757
+ }
2615
1758
  //# sourceMappingURL=smart-server.js.map