@mcp-abap-adt/llm-agent-server-libs 19.2.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 (179) 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/factories/controller-factory.d.ts +2 -2
  6. package/dist/factories/controller-factory.d.ts.map +1 -1
  7. package/dist/factories/controller-factory.js +2 -1
  8. package/dist/factories/controller-factory.js.map +1 -1
  9. package/dist/generated/version.d.ts +1 -1
  10. package/dist/generated/version.js +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/pipelines/controller.d.ts +4 -2
  16. package/dist/pipelines/controller.d.ts.map +1 -1
  17. package/dist/pipelines/controller.js +33 -8
  18. package/dist/pipelines/controller.js.map +1 -1
  19. package/dist/pipelines/coordinator-resolvers.d.ts +68 -0
  20. package/dist/pipelines/coordinator-resolvers.d.ts.map +1 -0
  21. package/dist/pipelines/coordinator-resolvers.js +97 -0
  22. package/dist/pipelines/coordinator-resolvers.js.map +1 -0
  23. package/dist/pipelines/dag.d.ts +2 -2
  24. package/dist/pipelines/dag.js +2 -2
  25. package/dist/pipelines/parsers.d.ts +1 -1
  26. package/dist/pipelines/parsers.d.ts.map +1 -1
  27. package/dist/pipelines/parsers.js +5 -4
  28. package/dist/pipelines/parsers.js.map +1 -1
  29. package/dist/pipelines/register-skill-sources.d.ts.map +1 -1
  30. package/dist/pipelines/register-skill-sources.js +5 -0
  31. package/dist/pipelines/register-skill-sources.js.map +1 -1
  32. package/dist/pipelines/stepper.d.ts +2 -2
  33. package/dist/pipelines/stepper.js +2 -2
  34. package/dist/smart-agent/build-stepper-root.d.ts +3 -2
  35. package/dist/smart-agent/build-stepper-root.d.ts.map +1 -1
  36. package/dist/smart-agent/build-stepper-root.js +2 -1
  37. package/dist/smart-agent/build-stepper-root.js.map +1 -1
  38. package/dist/smart-agent/config-reload-watcher.d.ts +30 -0
  39. package/dist/smart-agent/config-reload-watcher.d.ts.map +1 -0
  40. package/dist/smart-agent/config-reload-watcher.js +101 -0
  41. package/dist/smart-agent/config-reload-watcher.js.map +1 -0
  42. package/dist/smart-agent/config-validator.d.ts +21 -0
  43. package/dist/smart-agent/config-validator.d.ts.map +1 -0
  44. package/dist/smart-agent/config-validator.js +196 -0
  45. package/dist/smart-agent/config-validator.js.map +1 -0
  46. package/dist/smart-agent/config.d.ts +16 -254
  47. package/dist/smart-agent/config.d.ts.map +1 -1
  48. package/dist/smart-agent/config.js +17 -904
  49. package/dist/smart-agent/config.js.map +1 -1
  50. package/dist/smart-agent/controller/board.d.ts +6 -3
  51. package/dist/smart-agent/controller/board.d.ts.map +1 -1
  52. package/dist/smart-agent/controller/board.js +21 -0
  53. package/dist/smart-agent/controller/board.js.map +1 -1
  54. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts +16 -50
  55. package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -1
  56. package/dist/smart-agent/controller/controller-coordinator-handler.js +83 -371
  57. package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -1
  58. package/dist/smart-agent/controller/finalizer.d.ts +5 -0
  59. package/dist/smart-agent/controller/finalizer.d.ts.map +1 -1
  60. package/dist/smart-agent/controller/finalizer.js +12 -5
  61. package/dist/smart-agent/controller/finalizer.js.map +1 -1
  62. package/dist/smart-agent/controller/parser.d.ts +11 -0
  63. package/dist/smart-agent/controller/parser.d.ts.map +1 -0
  64. package/dist/smart-agent/controller/parser.js +77 -0
  65. package/dist/smart-agent/controller/parser.js.map +1 -0
  66. package/dist/smart-agent/controller/planner.d.ts +20 -16
  67. package/dist/smart-agent/controller/planner.d.ts.map +1 -1
  68. package/dist/smart-agent/controller/planner.js +58 -64
  69. package/dist/smart-agent/controller/planner.js.map +1 -1
  70. package/dist/smart-agent/controller/recall.d.ts +48 -0
  71. package/dist/smart-agent/controller/recall.d.ts.map +1 -0
  72. package/dist/smart-agent/controller/recall.js +196 -0
  73. package/dist/smart-agent/controller/recall.js.map +1 -0
  74. package/dist/smart-agent/controller/reviewer.d.ts.map +1 -1
  75. package/dist/smart-agent/controller/reviewer.js +1 -1
  76. package/dist/smart-agent/controller/reviewer.js.map +1 -1
  77. package/dist/smart-agent/controller/types.d.ts +8 -9
  78. package/dist/smart-agent/controller/types.d.ts.map +1 -1
  79. package/dist/smart-agent/controller/usage-logging.d.ts +16 -0
  80. package/dist/smart-agent/controller/usage-logging.d.ts.map +1 -0
  81. package/dist/smart-agent/controller/usage-logging.js +39 -0
  82. package/dist/smart-agent/controller/usage-logging.js.map +1 -0
  83. package/dist/smart-agent/http/adapter-route-handler.d.ts +13 -0
  84. package/dist/smart-agent/http/adapter-route-handler.d.ts.map +1 -0
  85. package/dist/smart-agent/http/adapter-route-handler.js +76 -0
  86. package/dist/smart-agent/http/adapter-route-handler.js.map +1 -0
  87. package/dist/smart-agent/http/chat-route-handler.d.ts +15 -0
  88. package/dist/smart-agent/http/chat-route-handler.d.ts.map +1 -0
  89. package/dist/smart-agent/http/chat-route-handler.js +327 -0
  90. package/dist/smart-agent/http/chat-route-handler.js.map +1 -0
  91. package/dist/smart-agent/http/config-route-handler.d.ts +21 -0
  92. package/dist/smart-agent/http/config-route-handler.d.ts.map +1 -0
  93. package/dist/smart-agent/http/config-route-handler.js +149 -0
  94. package/dist/smart-agent/http/config-route-handler.js.map +1 -0
  95. package/dist/smart-agent/http/health-route-handler.d.ts +12 -0
  96. package/dist/smart-agent/http/health-route-handler.d.ts.map +1 -0
  97. package/dist/smart-agent/http/health-route-handler.js +19 -0
  98. package/dist/smart-agent/http/health-route-handler.js.map +1 -0
  99. package/dist/smart-agent/http/models-route-handler.d.ts +17 -0
  100. package/dist/smart-agent/http/models-route-handler.d.ts.map +1 -0
  101. package/dist/smart-agent/http/models-route-handler.js +65 -0
  102. package/dist/smart-agent/http/models-route-handler.js.map +1 -0
  103. package/dist/smart-agent/http/response-helpers.d.ts +31 -0
  104. package/dist/smart-agent/http/response-helpers.d.ts.map +1 -0
  105. package/dist/smart-agent/http/response-helpers.js +59 -0
  106. package/dist/smart-agent/http/response-helpers.js.map +1 -0
  107. package/dist/smart-agent/http/route-table.d.ts +44 -0
  108. package/dist/smart-agent/http/route-table.d.ts.map +1 -0
  109. package/dist/smart-agent/http/route-table.js +35 -0
  110. package/dist/smart-agent/http/route-table.js.map +1 -0
  111. package/dist/smart-agent/http/session-cookie.d.ts +10 -0
  112. package/dist/smart-agent/http/session-cookie.d.ts.map +1 -0
  113. package/dist/smart-agent/http/session-cookie.js +16 -0
  114. package/dist/smart-agent/http/session-cookie.js.map +1 -0
  115. package/dist/smart-agent/http/sessions-route-handler.d.ts +8 -0
  116. package/dist/smart-agent/http/sessions-route-handler.d.ts.map +1 -0
  117. package/dist/smart-agent/http/sessions-route-handler.js +54 -0
  118. package/dist/smart-agent/http/sessions-route-handler.js.map +1 -0
  119. package/dist/smart-agent/http/usage-route-handler.d.ts +12 -0
  120. package/dist/smart-agent/http/usage-route-handler.d.ts.map +1 -0
  121. package/dist/smart-agent/http/usage-route-handler.js +28 -0
  122. package/dist/smart-agent/http/usage-route-handler.js.map +1 -0
  123. package/dist/smart-agent/knowledge/make-knowledge-backend.d.ts +13 -0
  124. package/dist/smart-agent/knowledge/make-knowledge-backend.d.ts.map +1 -0
  125. package/dist/smart-agent/knowledge/make-knowledge-backend.js +18 -0
  126. package/dist/smart-agent/knowledge/make-knowledge-backend.js.map +1 -0
  127. package/dist/smart-agent/llm/role-llm-resolver.d.ts +30 -0
  128. package/dist/smart-agent/llm/role-llm-resolver.d.ts.map +1 -0
  129. package/dist/smart-agent/llm/role-llm-resolver.js +44 -0
  130. package/dist/smart-agent/llm/role-llm-resolver.js.map +1 -0
  131. package/dist/smart-agent/llm-config-map.d.ts +38 -0
  132. package/dist/smart-agent/llm-config-map.d.ts.map +1 -0
  133. package/dist/smart-agent/llm-config-map.js +75 -0
  134. package/dist/smart-agent/llm-config-map.js.map +1 -0
  135. package/dist/smart-agent/mcp-readiness-monitor.d.ts +38 -0
  136. package/dist/smart-agent/mcp-readiness-monitor.d.ts.map +1 -0
  137. package/dist/smart-agent/mcp-readiness-monitor.js +81 -0
  138. package/dist/smart-agent/mcp-readiness-monitor.js.map +1 -0
  139. package/dist/smart-agent/mcp-readiness-registry.d.ts +37 -0
  140. package/dist/smart-agent/mcp-readiness-registry.d.ts.map +1 -0
  141. package/dist/smart-agent/mcp-readiness-registry.js +55 -0
  142. package/dist/smart-agent/mcp-readiness-registry.js.map +1 -0
  143. package/dist/smart-agent/resolve-config-sections.d.ts +15 -0
  144. package/dist/smart-agent/resolve-config-sections.d.ts.map +1 -0
  145. package/dist/smart-agent/resolve-config-sections.js +212 -0
  146. package/dist/smart-agent/resolve-config-sections.js.map +1 -0
  147. package/dist/smart-agent/session-lifecycle/index.d.ts +117 -0
  148. package/dist/smart-agent/session-lifecycle/index.d.ts.map +1 -0
  149. package/dist/smart-agent/session-lifecycle/index.js +152 -0
  150. package/dist/smart-agent/session-lifecycle/index.js.map +1 -0
  151. package/dist/smart-agent/skill-plugins-config.d.ts +9 -1
  152. package/dist/smart-agent/skill-plugins-config.d.ts.map +1 -1
  153. package/dist/smart-agent/skill-plugins-config.js +27 -1
  154. package/dist/smart-agent/skill-plugins-config.js.map +1 -1
  155. package/dist/smart-agent/skill-plugins-host-factory.d.ts +14 -2
  156. package/dist/smart-agent/skill-plugins-host-factory.d.ts.map +1 -1
  157. package/dist/smart-agent/skill-plugins-host-factory.js +16 -4
  158. package/dist/smart-agent/skill-plugins-host-factory.js.map +1 -1
  159. package/dist/smart-agent/smart-server.d.ts +151 -222
  160. package/dist/smart-agent/smart-server.d.ts.map +1 -1
  161. package/dist/smart-agent/smart-server.js +529 -1385
  162. package/dist/smart-agent/smart-server.js.map +1 -1
  163. package/dist/smart-agent/stepper-config.d.ts +138 -0
  164. package/dist/smart-agent/stepper-config.d.ts.map +1 -0
  165. package/dist/smart-agent/stepper-config.js +216 -0
  166. package/dist/smart-agent/stepper-config.js.map +1 -0
  167. package/dist/smart-agent/tools-rag-handle.d.ts +10 -0
  168. package/dist/smart-agent/tools-rag-handle.d.ts.map +1 -0
  169. package/dist/smart-agent/tools-rag-handle.js +76 -0
  170. package/dist/smart-agent/tools-rag-handle.js.map +1 -0
  171. package/dist/smart-agent/workers/worker-registry.d.ts +159 -0
  172. package/dist/smart-agent/workers/worker-registry.d.ts.map +1 -0
  173. package/dist/smart-agent/workers/worker-registry.js +203 -0
  174. package/dist/smart-agent/workers/worker-registry.js.map +1 -0
  175. package/dist/smart-agent/yaml-loader.d.ts +7 -0
  176. package/dist/smart-agent/yaml-loader.d.ts.map +1 -0
  177. package/dist/smart-agent/yaml-loader.js +146 -0
  178. package/dist/smart-agent/yaml-loader.js.map +1 -0
  179. 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
  (() => {
@@ -659,7 +450,8 @@ export class SmartServer {
659
450
  new LinearPipelinePlugin(),
660
451
  new DagPipelinePlugin(),
661
452
  new StepperPipelinePlugin(),
662
- new ControllerPipelinePlugin(),
453
+ new ControllerPipelinePlugin('controller', 'smart-executor'),
454
+ new ControllerPipelinePlugin('controller-weak', 'weak-executor'),
663
455
  ]) {
664
456
  pipelineRegistry.set(builtin.name, builtin);
665
457
  }
@@ -681,9 +473,19 @@ export class SmartServer {
681
473
  ...this.cfg.embedderFactories, // config takes precedence over plugins
682
474
  };
683
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
+ });
684
486
  // Resolve the embedder ONCE so the same instance feeds both makeRag and the
685
487
  // subagent context-builder's toolSource (#137). See resolve-agent-embedder.
686
- 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);
687
489
  // Hold the resolved embedder so buildServerCtx can thread it onto every
688
490
  // pipeline context (the controller pipeline needs it for target-state).
689
491
  this._resolvedEmbedder = resolvedEmbedder;
@@ -694,11 +496,15 @@ export class SmartServer {
694
496
  // llm-agent-rag). Absent `skillPlugins:` → no host, behaviour unchanged.
695
497
  if (this.cfg.skillPlugins) {
696
498
  const skillCfg = this.cfg.skillPlugins;
697
- 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);
698
504
  // Prefetch the named embedder factory only when we will actually build a
699
505
  // dedicated one (the agent embedder is already prefetched + wrapped).
700
506
  if (!reuseAgentEmbedder) {
701
- await prefetchEmbedderFactories([
507
+ await this._deps.prefetchEmbedderFactories([
702
508
  skillCfg.embedder?.provider ?? 'ollama',
703
509
  ]);
704
510
  }
@@ -706,33 +512,36 @@ export class SmartServer {
706
512
  // captured pg pools are ended INSIDE initSkillHost (the later closeFns
707
513
  // cleanup never runs when start() rejects before returning a handle), so
708
514
  // the pools cannot leak open sockets on a startup failure.
709
- this._skillHost = await initSkillHost(() => buildSkillHostFromConfig(skillCfg, {
710
- resolveEmbedder: (ec) => reuseAgentEmbedder
711
- ? resolvedEmbedder
712
- : resolveEmbedder(ec, {
713
- extraFactories: mergedEmbedderFactories,
714
- }),
715
- // Real pg `Pool` provider for a `postgres` catalog (qdrant
716
- // deployment). Lazily imports `pg` and ensures the catalog table
717
- // exists on first use; pass the configured table so the DDL targets
718
- // the SAME table the catalog store reads/writes. Absent
719
- // skillPlugins.catalog.type:postgres this is never invoked.
720
- makePgPool: (connectionString) => {
721
- const pool = makePgPool(connectionString, skillCfg.catalog.type === 'postgres'
722
- ? skillCfg.catalog.table
723
- : undefined);
724
- this._skillPgPools.push(pool);
725
- return pool;
726
- },
727
- // READ-ONLY pg pool for the recall-only path — NEVER runs DDL, so a
728
- // recall-only process with read-only pg credentials does not crash
729
- // attempting to CREATE the catalog table it only reads.
730
- makePgReadPool: (connectionString) => {
731
- const pool = makePgReadPool(connectionString);
732
- this._skillPgPools.push(pool);
733
- return pool;
734
- },
735
- }), 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);
736
545
  }
737
546
  // ---- RAG resolution (interface-only) ----------------------------------
738
547
  // Resolve the tools/history stores and any named collections HERE so the
@@ -757,9 +566,12 @@ export class SmartServer {
757
566
  // block above is the single source of truth for the tools/history stores.
758
567
  // Deployments that previously declared `pipeline.rag.{name}` collections must
759
568
  // move them to top-level `rag:` (or register them as plugin RAG).
760
- // MCP clients (DI > plugin > YAML). The YAML `mcp:` block is NOT pre-connected
761
- // here — see the branch below.
762
- 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 ??
763
575
  (plugins.mcpClients.length > 0 ? plugins.mcpClients : undefined);
764
576
  // ---- Knowledge backend (no MCP dependency) ----------------------------
765
577
  // The remaining shared pipeline infra (`_sharedMcpClients` + the
@@ -768,48 +580,65 @@ export class SmartServer {
768
580
  // connects + vectorizes (the builder owns that single connection).
769
581
  this.buildKnowledgeBackend();
770
582
  // ---- MCP connection strategy (exactly ONE connection) -----------------
771
- // Two client sources, two orderings — both keep ONE MCP connection AND a
772
- // 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:
773
586
  //
774
- // • DI/plugin clients present → inject them into the startup builder via
775
- // `withMcpClients` (builder.ts:923 short-circuits its own `cfg.mcp`
776
- // auto-connect). These pre-built clients were never vectorized by the
777
- // builder (unchanged behavior). `_sharedMcpClients` = that exact set,
778
- // 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.
779
592
  //
780
- // • YAML-only (no DI/plugin) → do NOT pre-connect and do NOT inject. The
781
- // startup builder receives `cfg.mcp` (via buildBaseBuilder, since
782
- // `mcpClients` is undefined) so `build()` CONNECTS the YAML block AND
783
- // VECTORIZES the tools into `toolsRag` (the `IRag`). AFTER `build()` we
784
- // harvest its connected set into `_sharedMcpClients` so `ctx.callMcp`
785
- // and per-session agents reuse the SAME single connection, then build
786
- // the `_toolsRagHandle` catalog over them. (Restores the
787
- // tool-vectorization the inject-skip regressed, with no double-connect.)
788
- // DI precedence semantics: an explicitly-provided client set (even an EMPTY
789
- // array) overrides YAML `mcp:`. `cfg.mcpClients: []` is a deliberate "disable
790
- // MCP / override plugin+YAML" signal — it must take the DI branch (inject `[]`
791
- // → builder short-circuits via withMcpClients([]) → no YAML auto-connect), NOT
792
- // fall through to the YAML branch. So gate on presence (`!== undefined`), not
793
- // length. (`diOrPluginMcpClients` is already undefined when neither DI nor a
794
- // non-empty plugin set was provided — see its resolution above.)
795
- 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;
796
614
  let mcpClients;
797
- if (hasDiOrPlugin) {
798
- // DI/plugin branch — resolve `_sharedMcpClients` + tools-RAG handle NOW
799
- // (knowledge backend is idempotent; already built above).
800
- await this.buildSharedPipelineInfra({
801
- toolsRag,
802
- resolvedEmbedder,
803
- mcpClients: diOrPluginMcpClients,
804
- });
615
+ if (hasReadyClients) {
805
616
  mcpClients = diOrPluginMcpClients;
806
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
+ }
807
626
  else {
808
- // YAML-only / no-mcp branch — let the builder connect + vectorize from
809
- // `cfg.mcp`; `_sharedMcpClients` + the tools-RAG handle are resolved from
810
- // 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).
811
629
  mcpClients = undefined;
812
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
+ }
813
642
  // Build SubAgentRegistry from `subagents:` YAML block (if present).
814
643
  // Each sub-agent is a minimal SmartAgent reusing the parent's plugin
815
644
  // outputs (embedder factories, plugins) but with its own LLM/RAG/MCP/etc.
@@ -860,13 +689,14 @@ export class SmartServer {
860
689
  const agentHandle = await builder.build();
861
690
  const { agent: smartAgent, chat, streamChat, close: closeAgent, circuitBreakers, ragStores, modelProvider, } = agentHandle;
862
691
  const { ragRegistry: globalRagRegistry, mcpClients: globalMcpClients } = agentHandle;
863
- // ---- YAML-only MCP harvest (single-connect + restored vectorization) ----
864
- // For the YAML-only branch the startup builder connected the `mcp:` block
865
- // AND vectorized its tools into `toolsRag`. Harvest the builder's connected
866
- // set into `_sharedMcpClients` so `ctx.callMcp` and per-session agents reuse
867
- // the SAME single connection (no second connect), then build the tools-RAG
868
- // handle catalog over it. The DI/plugin branch already did this earlier.
869
- 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) {
870
700
  this._sharedMcpClients = globalMcpClients ?? [];
871
701
  await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
872
702
  }
@@ -941,7 +771,7 @@ export class SmartServer {
941
771
  // worker-owned MCP clients themselves disconnect. Ordering matters —
942
772
  // closing MCP clients while a session graph is mid-use would cut its
943
773
  // request short.
944
- await drainWorkerCache(this._workerLlmCache);
774
+ await this._workers.drain();
945
775
  });
946
776
  const startTime = Date.now();
947
777
  const healthChecker = new HealthChecker({
@@ -955,106 +785,131 @@ export class SmartServer {
955
785
  // with tool vectorization (146+ embedding calls).
956
786
  // ---- Config hot-reload (optional) ------------------------------------
957
787
  if (this.cfg.configFile) {
958
- const watcher = new ConfigWatcher(this.cfg.configFile);
959
- watcher.on('reload', (update) => {
960
- log({ event: 'config_reload', update });
961
- // Apply agent config updates
962
- const agentUpdate = {};
963
- if (update.maxIterations !== undefined)
964
- agentUpdate.maxIterations = update.maxIterations;
965
- if (update.maxToolCalls !== undefined)
966
- agentUpdate.maxToolCalls = update.maxToolCalls;
967
- if (update.ragQueryK !== undefined)
968
- agentUpdate.ragQueryK = update.ragQueryK;
969
- if (update.toolUnavailableTtlMs !== undefined)
970
- agentUpdate.toolUnavailableTtlMs = update.toolUnavailableTtlMs;
971
- if (update.showReasoning !== undefined)
972
- agentUpdate.showReasoning = update.showReasoning;
973
- if (update.historyAutoSummarizeLimit !== undefined)
974
- agentUpdate.historyAutoSummarizeLimit =
975
- update.historyAutoSummarizeLimit;
976
- if (update.prompts?.ragTranslate !== undefined)
977
- agentUpdate.ragTranslatePrompt = update.prompts.ragTranslate;
978
- if (update.prompts?.historySummary !== undefined)
979
- agentUpdate.historySummaryPrompt = update.prompts.historySummary;
980
- if (update.classificationEnabled !== undefined)
981
- agentUpdate.classificationEnabled = update.classificationEnabled;
982
- if (Object.keys(agentUpdate).length > 0) {
983
- smartAgent.applyConfigUpdate(agentUpdate);
984
- // Mirror onto `this.cfg.agent` so freshly-built session graphs
985
- // (which read `this.cfg.agent` in `buildSessionAgent`) observe the
986
- // update. Deep-merge to preserve untouched startup fields.
987
- // Note: `agentUpdate` includes flat fields ONLY whitelisted by
988
- // `AGENT_CONFIG_FIELDS` plus the two prompt fields, which we route
989
- // into `this.cfg.prompts` separately below.
990
- const agentPatch = {};
991
- for (const k of Object.keys(agentUpdate)) {
992
- if (k !== 'ragTranslatePrompt' && k !== 'historySummaryPrompt') {
993
- agentPatch[k] = agentUpdate[k];
994
- }
995
- }
788
+ const reloadWatcher = new ConfigReloadWatcher({
789
+ configFile: this.cfg.configFile,
790
+ log,
791
+ applyAgentUpdate: (u) => smartAgent.applyConfigUpdate(u),
792
+ mirrorCfg: (agentPatch, prompts) => {
996
793
  if (Object.keys(agentPatch).length > 0) {
997
- const mergedAgent = {
794
+ this.cfg.agent = {
998
795
  ...(this.cfg.agent ??
999
796
  {}),
1000
797
  ...agentPatch,
1001
798
  };
1002
- this.cfg.agent =
1003
- mergedAgent;
1004
799
  }
1005
- if (update.prompts?.ragTranslate !== undefined ||
1006
- update.prompts?.historySummary !== undefined) {
1007
- const mergedPrompts = {
800
+ if (prompts.ragTranslate !== undefined ||
801
+ prompts.historySummary !== undefined) {
802
+ const merged = {
1008
803
  ...(this.cfg.prompts ??
1009
804
  {}),
1010
805
  };
1011
- if (update.prompts?.ragTranslate !== undefined) {
1012
- mergedPrompts.ragTranslate = update.prompts.ragTranslate;
1013
- }
1014
- if (update.prompts?.historySummary !== undefined) {
1015
- mergedPrompts.historySummary = update.prompts.historySummary;
1016
- }
806
+ if (prompts.ragTranslate !== undefined)
807
+ merged.ragTranslate = prompts.ragTranslate;
808
+ if (prompts.historySummary !== undefined)
809
+ merged.historySummary = prompts.historySummary;
1017
810
  this.cfg.prompts =
1018
- mergedPrompts;
1019
- }
1020
- }
1021
- // Per-session graphs (built by SessionGraphFactory) captured the OLD
1022
- // config and the OLD cached worker LLM set. Without invalidation,
1023
- // existing sessions keep the stale SmartAgent and a fresh acquire on a
1024
- // cookie-known sessionId still returns it. Clear the worker cache so
1025
- // the next build reads from the just-applied config, then drop every
1026
- // session graph. Failures are non-fatal — log and continue.
1027
- // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing
1028
- // the cache. Hot-reload runs from a synchronous emitter callback, so
1029
- // fire-and-forget here — same async-tolerance as the invalidateAll
1030
- // call below.
1031
- drainWorkerCache(this._workerLlmCache).catch((err) => {
1032
- log({ event: 'config_reload_drain_error', error: String(err) });
1033
- });
1034
- this._lifecycle?.invalidateAll().catch((err) => {
1035
- log({ event: 'config_reload_invalidate_error', error: String(err) });
1036
- });
1037
- // Apply RAG weight updates
1038
- if (update.vectorWeight !== undefined ||
1039
- update.keywordWeight !== undefined) {
1040
- for (const store of Object.values(ragStores)) {
1041
- if (store &&
1042
- typeof store.updateWeights === 'function') {
1043
- store.updateWeights({
1044
- vectorWeight: update.vectorWeight,
1045
- keywordWeight: update.keywordWeight,
1046
- });
1047
- }
811
+ merged;
1048
812
  }
1049
- }
1050
- });
1051
- watcher.on('error', (err) => {
1052
- log({ event: 'config_reload_error', error: String(err) });
813
+ },
814
+ drainWorkers: () => this._workers.drain(),
815
+ invalidateSessions: () => this._lifecycle?.invalidateAll() ?? Promise.resolve(),
816
+ ragStores,
1053
817
  });
1054
- watcher.start();
1055
- closeFns.push(() => watcher.stop());
818
+ reloadWatcher.start();
819
+ closeFns.push(() => reloadWatcher.stop());
1056
820
  }
1057
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;
1058
913
  const server = http.createServer((req, res) => this._handle(req, res, requestLogger, smartAgent, chat, streamChat, log, healthChecker, modelProvider, adapterMap).catch((err) => {
1059
914
  if (!res.headersSent) {
1060
915
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1081,8 +936,9 @@ export class SmartServer {
1081
936
  // 2. Now run lifecycle cleanup: sweep timer, lifecycle.disposeAll,
1082
937
  // config watcher stop, agent close. By this point no HTTP
1083
938
  // request is in flight, so disposing session graphs is safe.
1084
- for (const fn of closeFns)
1085
- await fn();
939
+ // `built.close()` runs the same `closeFns` loop the original
940
+ // inline close did — order preserved.
941
+ await built.close();
1086
942
  },
1087
943
  requestLogger,
1088
944
  });
@@ -1139,7 +995,7 @@ export class SmartServer {
1139
995
  const classifierTemp = Number(subFlatLlm?.classifierTemperature ?? 0.1);
1140
996
  const cached = await resolveWorkerLlmSet({
1141
997
  name,
1142
- cache: this._workerLlmCache,
998
+ cache: this._workers.cache,
1143
999
  // Preserve the existing makeLlm derivation exactly.
1144
1000
  makeMain: () => makeLlm({
1145
1001
  // ?? 'deepseek' is a TS type-narrowing net only; the config
@@ -1249,7 +1105,7 @@ export class SmartServer {
1249
1105
  // re-wires never overwrite the cache. See backfillWorkerCacheFromHandle's
1250
1106
  // doc-comment for the rationale.
1251
1107
  if (!injected) {
1252
- const entry = this._workerLlmCache.get(name);
1108
+ const entry = this._workers.cache.get(name);
1253
1109
  if (entry)
1254
1110
  await backfillWorkerCacheFromHandle(entry, handle);
1255
1111
  }
@@ -1257,32 +1113,24 @@ export class SmartServer {
1257
1113
  }
1258
1114
  // -- Pipeline-context dep sources (promoted from the inline coordinator-gate
1259
1115
  // closures; consumed by buildServerCtx, which later tasks call) ----------
1260
- /** 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. */
1261
1119
  _makeLlm(lc) {
1262
- return makeLlm({
1263
- provider: lc.provider ?? 'deepseek',
1264
- apiKey: lc.apiKey,
1265
- baseURL: lc.url,
1266
- model: lc.model,
1267
- }, 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);
1268
1125
  }
1269
1126
  /** Resolve a per-role LLM through the normalized map → pipelineFallback chain.
1270
1127
  * 'main' returns the captured mainLlm; 'helper'/'classifier' return the
1271
1128
  * prebuilt instances when present; otherwise the map/fallback config is built. */
1272
1129
  async resolveRoleLlm(role) {
1273
- if (role === 'main' && this._mainLlm)
1274
- return this._mainLlm;
1275
- if ((role === 'helper' || role === 'planner') && this._helperLlm) {
1276
- return this._helperLlm;
1130
+ if (!this._roleLlm) {
1131
+ throw new Error('resolveRoleLlm invoked before _buildInfra built the resolver');
1277
1132
  }
1278
- if (role === 'classifier' && this._classifierLlm)
1279
- return this._classifierLlm;
1280
- const cfg = resolveLlmConfig(this._llmMap, role, this._pipelineFallback);
1281
- if (cfg)
1282
- return this._makeLlm(cfg);
1283
- if (this._mainLlm)
1284
- return this._mainLlm;
1285
- throw new Error(`cannot resolve LLM for role '${role}': no config`);
1133
+ return this._roleLlm.resolve(role);
1286
1134
  }
1287
1135
  /**
1288
1136
  * Session-scoped knowledge RAG over the shared knowledge backend (built
@@ -1311,7 +1159,7 @@ export class SmartServer {
1311
1159
  }
1312
1160
  /** callMcp bridge over the shared connected MCP clients (empty when none). */
1313
1161
  callMcp(name, args, signal) {
1314
- return buildMcpBridge(this._sharedMcpClients ?? [])(name, args, signal);
1162
+ return buildMcpBridge(this._sharedMcpClients ?? [], this._mcpFailureClassifier)(name, args, signal);
1315
1163
  }
1316
1164
  _mintStepperId() {
1317
1165
  return randomUUID();
@@ -1346,7 +1194,7 @@ export class SmartServer {
1346
1194
  // connect the YAML `mcp:` block ONCE (connect is not safe to invoke twice
1347
1195
  // on the same wrapper — guard via the cache field).
1348
1196
  if (!mcpClients && !this._stepperMcpClients) {
1349
- this._stepperMcpClients = await connectMcpClientsFromConfig(this.cfg.mcp);
1197
+ this._stepperMcpClients = await this._deps.connectMcp(this.cfg.mcp);
1350
1198
  }
1351
1199
  this._sharedMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
1352
1200
  await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
@@ -1361,19 +1209,10 @@ export class SmartServer {
1361
1209
  buildKnowledgeBackend() {
1362
1210
  if (this._stepperKnowledgeBackend)
1363
1211
  return;
1364
- const logDir = this.cfg.logDir;
1365
- // Attach an embedder-backed semantic index whenever an embedder is resolved —
1366
- // for ANY pipeline. Do NOT throw here: buildKnowledgeBackend runs
1367
- // unconditionally at startup and a flat/stepper deployment without an embedder
1368
- // is valid; only the CONTROLLER mandates embedding recall, enforced at the
1369
- // ControllerFactory boundary, not globally. With an index, the controller's
1370
- // results-RAG recall ranks by meaning instead of recency.
1371
- const semantic = this._resolvedEmbedder
1372
- ? makeKnowledgeSemanticIndex(this._resolvedEmbedder)
1373
- : undefined;
1374
- this._stepperKnowledgeBackend = logDir
1375
- ? new JsonlKnowledgeBackend(logDir, semantic)
1376
- : new InMemoryKnowledgeBackend(semantic);
1212
+ this._stepperKnowledgeBackend = makeKnowledgeBackend({
1213
+ logDir: this.cfg.logDir,
1214
+ embedder: this._resolvedEmbedder,
1215
+ });
1377
1216
  }
1378
1217
  /**
1379
1218
  * Build `_toolsRagHandle` — a real IToolsRagHandle over the tools RAG store +
@@ -1387,71 +1226,7 @@ export class SmartServer {
1387
1226
  */
1388
1227
  async buildToolsRagHandle(input) {
1389
1228
  const { toolsRag, resolvedEmbedder } = input;
1390
- // Tools RAG handle over the tools store + MCP catalog.
1391
- const stepperMcpClients = this._sharedMcpClients ?? [];
1392
- let catalogCache;
1393
- const ensureCatalog = async () => {
1394
- if (catalogCache)
1395
- return catalogCache;
1396
- const catalog = new Map();
1397
- await Promise.allSettled(stepperMcpClients.map(async (client) => {
1398
- const result = await client.listTools();
1399
- if (result.ok) {
1400
- for (const t of result.value) {
1401
- if (!catalog.has(t.name))
1402
- catalog.set(t.name, t);
1403
- }
1404
- }
1405
- }));
1406
- catalogCache = catalog;
1407
- return catalog;
1408
- };
1409
- this._toolsRagHandle = {
1410
- async query(text, k, options) {
1411
- const limit = k ?? 20;
1412
- const catalog = await ensureCatalog();
1413
- if (toolsRag && resolvedEmbedder) {
1414
- // Pass options (requestLogger + trace) so the wrapped embedder logs
1415
- // this query-embedding against the request.
1416
- const embedding = new QueryEmbedding(text, resolvedEmbedder, options);
1417
- const ragResult = await toolsRag.query(embedding, limit);
1418
- if (ragResult.ok) {
1419
- const hits = [];
1420
- for (const r of ragResult.value) {
1421
- const id = r.metadata.id;
1422
- if (id?.startsWith('tool:')) {
1423
- const name = id.slice(5).replace(/:.*$/, '');
1424
- const tool = catalog.get(name);
1425
- if (tool)
1426
- hits.push(tool);
1427
- }
1428
- }
1429
- if (hits.length > 0)
1430
- return hits;
1431
- }
1432
- }
1433
- return [...catalog.values()].slice(0, limit);
1434
- },
1435
- lookup(name) {
1436
- return catalogCache?.get(name);
1437
- },
1438
- };
1439
- // F2: eagerly populate the MCP tool catalog at startup (MCP is connected
1440
- // above), so the SYNC `lookup(name)` contract (IToolsRagHandle.lookup) returns
1441
- // a tool schema BEFORE any `query()` runs. `ensureCatalog` is idempotent —
1442
- // later `query()` calls reuse the cached map. Guard against a catalog-load
1443
- // failure so startup never crashes: on failure `catalogCache` stays unset and
1444
- // `lookup` returns undefined (today's worst case), while the happy path works.
1445
- try {
1446
- await ensureCatalog();
1447
- }
1448
- catch (err) {
1449
- this.cfg.log?.({
1450
- event: 'tools_catalog_eager_load_failed',
1451
- message: 'tools catalog eager-load failed; lookup() returns undefined until first query()',
1452
- error: err instanceof Error ? err.message : String(err),
1453
- });
1454
- }
1229
+ this._toolsRagHandle = await makeToolsRagHandle(this._sharedMcpClients ?? [], toolsRag, resolvedEmbedder, this.cfg.log);
1455
1230
  }
1456
1231
  /**
1457
1232
  * Build the per-session pipeline instance from the registry. Selects the
@@ -1477,62 +1252,13 @@ export class SmartServer {
1477
1252
  /**
1478
1253
  * Build the FRESH per-session worker (sub-agent) registry from the SAME
1479
1254
  * `subagents:` configs the primary build() used, injecting globals + this
1480
- * session's logger + the CACHED per-worker LLM/embedder (this._workerLlmCache).
1255
+ * session's logger + the CACHED per-worker LLM/embedder (this._workers.cache).
1481
1256
  * NEVER reconstructs LLM clients; NEVER reuses the global registry.
1482
1257
  *
1483
- * Extracted from buildSessionAgent so both the legacy session re-wire and the
1484
- * pipeline-plugin context (`buildServerCtx` / `partsToBaseInput`) feed a real
1485
- * per-session worker map to `buildBaseBuilder` instead of an empty `new Map()`.
1258
+ * Delegates to `this._workers.build(parts)` (WorkerRegistry).
1486
1259
  */
1487
1260
  async buildWorkerRegistry(parts) {
1488
- const registry = new Map();
1489
- if (!this.cfg.subAgentConfigs || this.cfg.subAgentConfigs.length === 0) {
1490
- return registry;
1491
- }
1492
- if (!this._fileLogger) {
1493
- throw new Error('buildWorkerRegistry invoked before primary build() captured globals');
1494
- }
1495
- for (const sub of this.cfg.subAgentConfigs) {
1496
- // Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
1497
- // clears `_workerLlmCache`, the next session build used to throw
1498
- // "worker LLM set not cached" because the cache was assumed
1499
- // pre-populated by the primary build(). buildSubAgent itself routes
1500
- // through `resolveWorkerLlmSet` which is build-on-miss, so calling
1501
- // it without an `injected` arg rebuilds the cache entry. We then
1502
- // re-read the entry to honour the per-worker slot priority below.
1503
- if (!this._workerLlmCache.has(sub.name)) {
1504
- await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
1505
- }
1506
- const cached = this._workerLlmCache.get(sub.name);
1507
- if (!cached) {
1508
- // Defence in depth — should be impossible after the lazy build
1509
- // above unless buildSubAgent's contract changes.
1510
- throw new Error(`worker LLM set not cached for '${sub.name}'`);
1511
- }
1512
- // Per-worker injected slot priority (review HIGH #7):
1513
- // worker-cached (from the primary build, includes backfilled
1514
- // subCfg.mcp / subCfg.rag results) → parent's session-scoped
1515
- // fallback. Encoded HERE so buildSubAgent does not need to know
1516
- // the difference; it just consumes injected.mcpClients/toolsRag.
1517
- const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
1518
- ? cached.mcpClients
1519
- : parts.mcpClients;
1520
- const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
1521
- const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
1522
- ragRegistry: parts.ragRegistry,
1523
- toolsRag: injectedToolsRag,
1524
- mcpClients: injectedMcpClients,
1525
- requestLogger: parts.logger,
1526
- mainLlm: cached.mainLlm,
1527
- classifierLlm: cached.classifierLlm,
1528
- helperLlm: cached.helperLlm,
1529
- embedder: cached.embedder,
1530
- });
1531
- registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
1532
- description: sub.description,
1533
- }));
1534
- }
1535
- return registry;
1261
+ return this._workers.build(parts);
1536
1262
  }
1537
1263
  /**
1538
1264
  * Map SessionAgentParts → buildBaseBuilder input. `workerRegistry` is the
@@ -1621,6 +1347,7 @@ export class SmartServer {
1621
1347
  ragRegistry: scope.parts.ragRegistry,
1622
1348
  callMcp: (n, a, s) => this.callMcp(n, a, s),
1623
1349
  mcpClients: scope.parts.mcpClients,
1350
+ mcpFailureClassifier: this._mcpFailureClassifier,
1624
1351
  subagents: (this.cfg.subAgentConfigs ?? []).map((s) => ({
1625
1352
  name: s.name,
1626
1353
  description: s.description,
@@ -1751,6 +1478,8 @@ export class SmartServer {
1751
1478
  if (parts.workerRegistry.size > 0) {
1752
1479
  builder = builder.withSubAgents(parts.workerRegistry);
1753
1480
  }
1481
+ // Thread the instance-level MCP failure classifier (DI/programmatic only).
1482
+ builder = builder.withMcpFailureClassifier(this._mcpFailureClassifier);
1754
1483
  return builder;
1755
1484
  }
1756
1485
  /**
@@ -1759,7 +1488,7 @@ export class SmartServer {
1759
1488
  * session-scoped pipeline context (`buildServerCtx`) supplies the FRESH
1760
1489
  * per-session worker registry + session logger + the global
1761
1490
  * ragRegistry/toolsRag/mcpClients + the CACHED per-worker LLM/embedder
1762
- * (this._workerLlmCache) via `createAgentBuilder`. It NEVER reuses the primary
1491
+ * (this._workers.cache) via `createAgentBuilder`. It NEVER reuses the primary
1763
1492
  * build()'s global registry/coordinator and NEVER constructs new LLM clients.
1764
1493
  *
1765
1494
  * The pipeline returns `{ agent, close }`; we register `close` under the
@@ -1857,758 +1586,173 @@ export class SmartServer {
1857
1586
  url: rawUrl,
1858
1587
  normalizedPath: urlPath,
1859
1588
  });
1860
- if (req.method === 'GET' &&
1861
- (urlPath === '/v1/models' || urlPath === '/models')) {
1862
- const queryString = rawUrl.includes('?') ? rawUrl.split('?')[1] : '';
1863
- const queryParams = new URLSearchParams(queryString);
1864
- const excludeEmbedding = queryParams.get('exclude_embedding') === 'true';
1865
- let data = [
1866
- { id: 'smart-agent', object: 'model', owned_by: 'smart-agent' },
1867
- ];
1868
- if (modelProvider) {
1869
- const result = await modelProvider.getModels({ excludeEmbedding });
1870
- if (result.ok) {
1871
- data = result.value.map((m) => ({
1872
- id: m.id,
1873
- object: 'model',
1874
- owned_by: m.owned_by ?? 'unknown',
1875
- ...(m.displayName ? { display_name: m.displayName } : {}),
1876
- ...(m.provider ? { provider: m.provider } : {}),
1877
- ...(m.capabilities ? { capabilities: m.capabilities } : {}),
1878
- ...(m.contextLength ? { context_length: m.contextLength } : {}),
1879
- ...(m.streamingSupported !== undefined
1880
- ? { streaming_supported: m.streamingSupported }
1881
- : {}),
1882
- ...(m.deprecated !== undefined ? { deprecated: m.deprecated } : {}),
1883
- }));
1884
- }
1885
- }
1886
- res.writeHead(200, { 'Content-Type': 'application/json' });
1887
- res.end(JSON.stringify({ object: 'list', data }));
1888
- return;
1889
- }
1890
- if (req.method === 'GET' &&
1891
- (urlPath === '/v1/embedding-models' || urlPath === '/embedding-models')) {
1892
- let data = [];
1893
- if (modelProvider?.getEmbeddingModels) {
1894
- const result = await modelProvider.getEmbeddingModels();
1895
- if (result.ok) {
1896
- data = result.value.map((m) => ({
1897
- id: m.id,
1898
- object: 'model',
1899
- owned_by: m.owned_by ?? 'unknown',
1900
- ...(m.displayName ? { display_name: m.displayName } : {}),
1901
- ...(m.provider ? { provider: m.provider } : {}),
1902
- ...(m.capabilities ? { capabilities: m.capabilities } : {}),
1903
- ...(m.contextLength ? { context_length: m.contextLength } : {}),
1904
- ...(m.streamingSupported !== undefined
1905
- ? { streaming_supported: m.streamingSupported }
1906
- : {}),
1907
- ...(m.deprecated !== undefined ? { deprecated: m.deprecated } : {}),
1908
- }));
1909
- }
1910
- }
1911
- res.writeHead(200, { 'Content-Type': 'application/json' });
1912
- res.end(JSON.stringify({ object: 'list', data }));
1913
- return;
1914
- }
1915
- if (req.method === 'GET' && urlPath === '/v1/usage') {
1916
- const lifecycle = this._lifecycle;
1917
- if (!lifecycle) {
1918
- res.writeHead(500, { 'Content-Type': 'application/json' });
1919
- res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1920
- return;
1921
- }
1922
- const isHttps = req.socket.encrypted === true ||
1923
- req.headers['x-forwarded-proto'] === 'https';
1924
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1925
- if (resolved.minted && resolved.setCookie) {
1926
- res.setHeader('Set-Cookie', resolved.setCookie);
1927
- }
1928
- const sessionId = resolved.identity.sessionId;
1929
- const graph = await lifecycle.acquire(sessionId);
1930
- try {
1931
- res.writeHead(200, { 'Content-Type': 'application/json' });
1932
- res.end(JSON.stringify(graph.logger.getSummary()));
1933
- }
1934
- finally {
1935
- lifecycle.release(sessionId, graph);
1936
- }
1937
- return;
1938
- }
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
+ });
1939
1637
  // GET /v1/sessions — list sessions for the current identity
1940
- if (req.method === 'GET' && urlPath === '/v1/sessions') {
1941
- const lifecycle = this._lifecycle;
1942
- if (!lifecycle) {
1943
- res.writeHead(500, { 'Content-Type': 'application/json' });
1944
- res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1945
- return;
1946
- }
1947
- const isHttps = req.socket.encrypted === true ||
1948
- req.headers['x-forwarded-proto'] === 'https';
1949
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1950
- if (resolved.minted && resolved.setCookie) {
1951
- res.setHeader('Set-Cookie', resolved.setCookie);
1952
- }
1953
- const identity = resolved.identity.sessionId;
1954
- const body = await handleListSessions(this._sessionMetaStore, identity);
1955
- res.writeHead(200, { 'Content-Type': 'application/json' });
1956
- res.end(JSON.stringify(body));
1957
- return;
1958
- }
1638
+ table.add({
1639
+ method: 'GET',
1640
+ match: (p) => p === '/v1/sessions',
1641
+ handle: (rc) => handleSessionsList(rc, rc.server._lifecycle, rc.server._sessionMetaStore),
1642
+ });
1959
1643
  // POST /v1/sessions/:id/resume — resume a session
1960
- {
1961
- const resumeMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)\/resume$/);
1962
- if (req.method === 'POST' && resumeMatch) {
1963
- const sessionId = resumeMatch[1];
1964
- const lifecycle = this._lifecycle;
1965
- if (!lifecycle) {
1966
- res.writeHead(500, { 'Content-Type': 'application/json' });
1967
- res.end(jsonError('Session lifecycle not initialized', 'server_error'));
1968
- return;
1969
- }
1970
- const isHttps = req.socket.encrypted === true ||
1971
- req.headers['x-forwarded-proto'] === 'https';
1972
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1973
- if (resolved.minted && resolved.setCookie) {
1974
- res.setHeader('Set-Cookie', resolved.setCookie);
1975
- }
1976
- const identity = resolved.identity.sessionId;
1977
- const body = await handleResumeSession(this._sessionMetaStore, identity, sessionId);
1978
- const status = body.ok ? 200 : 404;
1979
- res.writeHead(status, { 'Content-Type': 'application/json' });
1980
- res.end(JSON.stringify(body));
1981
- return;
1982
- }
1983
- }
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
+ });
1984
1649
  // DELETE /v1/sessions/:id — delete a session
1985
- {
1986
- const deleteMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)$/);
1987
- if (req.method === 'DELETE' && deleteMatch) {
1988
- const sessionId = deleteMatch[1];
1989
- const lifecycle = this._lifecycle;
1990
- if (!lifecycle) {
1991
- res.writeHead(500, { 'Content-Type': 'application/json' });
1992
- 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));
1993
1666
  return;
1994
1667
  }
1995
- const isHttps = req.socket.encrypted === true ||
1996
- req.headers['x-forwarded-proto'] === 'https';
1997
- const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
1998
- if (resolved.minted && resolved.setCookie) {
1999
- res.setHeader('Set-Cookie', resolved.setCookie);
1668
+ if (rc.method === 'PUT') {
1669
+ await handleConfigUpdate(rc.req, rc.res, rc.smartAgent, this._configUpdateTarget());
1670
+ return;
2000
1671
  }
2001
- const identity = resolved.identity.sessionId;
2002
- const evictFn = async (sid) => {
2003
- // (a) Evict/dispose this session's graph from the registry.
2004
- await lifecycle.registry.evictOne(sid);
2005
- // (b) Evict the session's knowledge from the shared backend. This
2006
- // clears the long-lived in-memory backend AND removes the JSONL files
2007
- // (JsonlKnowledgeBackend.deleteSession), so a same-id re-entry never
2008
- // rehydrates stale entries — matching the README "evicts its
2009
- // knowledge-RAG entries" contract.
2010
- await this._stepperKnowledgeBackend?.deleteSession(sid);
2011
- };
2012
- const body = await handleDeleteSession(this._sessionMetaStore, identity, sessionId, evictFn);
2013
- const status = body.ok ? 200 : 404;
2014
- res.writeHead(status, { 'Content-Type': 'application/json' });
2015
- res.end(JSON.stringify(body));
2016
- return;
2017
- }
2018
- }
2019
- // /v1/config or /config
2020
- if (urlPath === '/v1/config' || urlPath === '/config') {
2021
- if (req.method === 'GET') {
2022
- const models = smartAgent.getActiveConfig();
2023
- const agent = smartAgent.getAgentConfig();
2024
- const body = { models, agent };
2025
- res.writeHead(200, { 'Content-Type': 'application/json' });
2026
- res.end(JSON.stringify(body));
2027
- return;
2028
- }
2029
- if (req.method === 'PUT') {
2030
- await this._handleConfigUpdate(req, res, smartAgent);
2031
- return;
2032
- }
2033
- // 405 for other methods
2034
- res.setHeader('Allow', 'GET, PUT, OPTIONS');
2035
- res.writeHead(405, { 'Content-Type': 'application/json' });
2036
- res.end(jsonError(`Method ${req.method} not allowed on ${urlPath}`, 'invalid_request_error'));
2037
- return;
2038
- }
2039
- if (req.method === 'GET' &&
2040
- (urlPath === '/health' || urlPath === '/v1/health')) {
2041
- const status = await healthChecker.check();
2042
- const httpCode = status.status === 'unhealthy' ? 503 : 200;
2043
- res.writeHead(httpCode, { 'Content-Type': 'application/json' });
2044
- res.end(JSON.stringify(status));
2045
- return;
2046
- }
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
+ });
2047
1683
  // POST /v1/messages or /messages → Anthropic adapter
2048
- if (req.method === 'POST' &&
2049
- (urlPath === '/v1/messages' || urlPath === '/messages')) {
2050
- const anthropicAdapter = adapterMap?.get('anthropic');
2051
- if (!anthropicAdapter) {
2052
- res.writeHead(404, { 'Content-Type': 'application/json' });
2053
- res.end(jsonError('Anthropic adapter not registered', 'not_found'));
2054
- return;
2055
- }
2056
- await this._withSession(req, res, async (graph, sessionId, traceId) => {
2057
- await this._handleAdapterRequest(req, res, graph.agent ?? smartAgent, anthropicAdapter, { sessionId, traceId, graph });
2058
- });
2059
- return;
2060
- }
2061
- if (req.method === 'POST' &&
2062
- (urlPath === '/v1/chat/completions' || urlPath === '/chat/completions')) {
2063
- await this._withSession(req, res, async (graph, sessionId, traceId) => {
2064
- await this._handleChat(req, res, requestLogger, graph.agent ?? smartAgent, chat, streamChat, log, modelProvider, { sessionId, traceId, graph });
2065
- });
2066
- return;
2067
- }
2068
- res.writeHead(404, { 'Content-Type': 'application/json' });
2069
- res.end(jsonError(`Cannot ${req.method} ${urlPath}`, 'invalid_request_error'));
2070
- }
2071
- async _handleAdapterRequest(req, res, agent, adapter, session) {
2072
- const raw = await readBody(req);
2073
- let body;
2074
- try {
2075
- body = JSON.parse(raw);
2076
- }
2077
- catch {
2078
- res.writeHead(400, { 'Content-Type': 'application/json' });
2079
- res.end(jsonError('Invalid JSON', 'invalid_request_error'));
2080
- return;
2081
- }
2082
- let normalized;
2083
- try {
2084
- normalized = adapter.normalizeRequest(body);
2085
- }
2086
- catch (err) {
2087
- if (err instanceof AdapterValidationError) {
2088
- res.writeHead(err.statusCode, { 'Content-Type': 'application/json' });
2089
- res.end(jsonError(err.message, 'invalid_request_error'));
2090
- return;
2091
- }
2092
- throw err;
2093
- }
2094
- // #171 (review#8): the adapter has already normalized Anthropic
2095
- // tool_use/tool_result blocks into the OpenAI-shaped Message[]
2096
- // (assistant.tool_calls + role:'tool' with tool_call_id). Run the same
2097
- // external-results extraction the OpenAI path uses so Anthropic clients get
2098
- // identical stateless-resume behaviour: consumed external turns are stripped
2099
- // and their results threaded to the agent keyed by deterministic `ext:` id.
2100
- const { results: externalResults, sanitizedMessages } = buildExternalResults(normalized.messages);
2101
- const augmentedOptions = session
2102
- ? {
2103
- ...normalized.options,
2104
- sessionId: session.sessionId,
2105
- trace: { traceId: session.traceId },
2106
- toolAvailability: session.graph.toolAvailability,
2107
- pendingToolResults: session.graph.pendingToolResults,
2108
- externalResults,
2109
- }
2110
- : { ...normalized.options, externalResults };
2111
- if (normalized.stream) {
2112
- res.writeHead(200, {
2113
- 'Content-Type': 'text/event-stream',
2114
- 'Cache-Control': 'no-cache',
2115
- Connection: 'keep-alive',
2116
- });
2117
- for await (const event of adapter.transformStream(agent.streamProcess(sanitizedMessages, augmentedOptions), normalized.context)) {
2118
- const eventLine = event.event ? `event: ${event.event}\n` : '';
2119
- res.write(`${eventLine}data: ${event.data}\n\n`);
2120
- }
2121
- res.end();
2122
- return;
2123
- }
2124
- // Non-streaming
2125
- const result = await agent.process(sanitizedMessages, augmentedOptions);
2126
- res.setHeader('Content-Type', 'application/json');
2127
- if (!result.ok) {
2128
- res.writeHead(500);
2129
- res.end(JSON.stringify(adapter.formatError?.(result.error, normalized.context) ?? {
2130
- error: {
2131
- message: result.error.message,
2132
- type: result.error.code,
2133
- },
2134
- }));
2135
- return;
2136
- }
2137
- res.writeHead(200);
2138
- res.end(JSON.stringify(adapter.formatResult(result.value, normalized.context)));
2139
- }
2140
- async _handleChat(req, res, _requestLogger, smartAgent, _chat, _streamChat, log, modelProvider, session) {
2141
- const rawBody = await readBody(req);
2142
- let parsed;
2143
- try {
2144
- parsed = JSON.parse(rawBody);
2145
- }
2146
- catch {
2147
- res.writeHead(400, { 'Content-Type': 'application/json' });
2148
- res.end(jsonError('Invalid JSON body', 'invalid_request_error'));
2149
- return;
2150
- }
2151
- if (typeof parsed !== 'object' ||
2152
- parsed === null ||
2153
- !Array.isArray(parsed.messages)) {
2154
- res.writeHead(400, { 'Content-Type': 'application/json' });
2155
- res.end(jsonError('messages must be a non-empty array', 'invalid_request_error'));
2156
- return;
2157
- }
2158
- const body = parsed;
2159
- const extractText = (c) => {
2160
- if (c === null || c === undefined)
2161
- return '';
2162
- if (typeof c === 'string')
2163
- return c;
2164
- if (!Array.isArray(c))
2165
- return '';
2166
- return c
2167
- .filter((b) => typeof b === 'object' &&
2168
- b !== null &&
2169
- b.type === 'text' &&
2170
- typeof b.text === 'string')
2171
- .map((b) => b.text)
2172
- .join('\n');
2173
- };
2174
- const userMessages = body.messages.filter((m) => m.role === 'user');
2175
- if (userMessages.length === 0) {
2176
- res.writeHead(400, { 'Content-Type': 'application/json' });
2177
- res.end(jsonError('at least one message with role "user" is required', 'invalid_request_error'));
2178
- return;
2179
- }
2180
- // Prefer the session injected by `_withSession` (cookie identity); fall
2181
- // back to the legacy x-session-id header / 'default' bucket only when no
2182
- // session was wired (defensive — production routes always inject one).
2183
- const traceId = session?.traceId ?? randomUUID();
2184
- const sessionId = session?.sessionId ??
2185
- req.headers['x-session-id'] ??
2186
- 'default';
2187
- const sessionLogger = new SessionLogger(this.cfg.logDir || null, sessionId, traceId);
2188
- const toolsValidationMode = this.cfg.agent?.externalToolsValidationMode ?? 'permissive';
2189
- const externalToolsValidation = normalizeAndValidateExternalTools(body.tools);
2190
- const externalTools = externalToolsValidation.tools;
2191
- if (externalToolsValidation.errors.length > 0) {
2192
- log({
2193
- event: 'invalid_external_tools_detected',
2194
- traceId,
2195
- sessionId,
2196
- mode: toolsValidationMode,
2197
- count: externalToolsValidation.errors.length,
2198
- errors: externalToolsValidation.errors,
2199
- });
2200
- sessionLogger.logStep('invalid_external_tools_detected', {
2201
- mode: toolsValidationMode,
2202
- count: externalToolsValidation.errors.length,
2203
- errors: externalToolsValidation.errors,
2204
- });
2205
- if (toolsValidationMode === 'strict') {
2206
- const firstError = externalToolsValidation.errors[0];
2207
- res.writeHead(400, { 'Content-Type': 'application/json' });
2208
- res.end(jsonValidationError(firstError.message, firstError.code, firstError.param));
2209
- return;
2210
- }
2211
- }
2212
- const t0 = Date.now();
2213
- log({ event: 'request_start', stream: body.stream ?? false, traceId });
2214
- const opts = {
2215
- stream: body.stream,
2216
- externalTools,
2217
- sessionId,
2218
- trace: { traceId },
2219
- sessionLogger,
2220
- model: body.model,
2221
- ...(session
2222
- ? {
2223
- toolAvailability: session.graph.toolAvailability,
2224
- pendingToolResults: session.graph.pendingToolResults,
2225
- }
2226
- : {}),
2227
- ...(body.temperature !== undefined
2228
- ? { temperature: body.temperature }
2229
- : {}),
2230
- ...(body.max_tokens !== undefined ? { maxTokens: body.max_tokens } : {}),
2231
- ...(body.top_p !== undefined ? { topP: body.top_p } : {}),
2232
- ...(body.stop !== undefined
2233
- ? { stop: Array.isArray(body.stop) ? body.stop : [body.stop] }
2234
- : {}),
2235
- };
2236
- const responseModel = body.model ?? modelProvider?.getModel() ?? 'smart-agent';
2237
- const normalizedMessages = body.messages
2238
- .map((m) => {
2239
- const role = m.role;
2240
- const normalizedMessage = {
2241
- role,
2242
- content: extractText(m.content),
2243
- };
2244
- if (role === 'tool') {
2245
- if (typeof m.tool_call_id === 'string' && m.tool_call_id.trim()) {
2246
- normalizedMessage.tool_call_id = m.tool_call_id;
2247
- }
2248
- else {
2249
- sessionLogger.logStep('drop_orphan_tool_message', {
2250
- reason: 'missing_tool_call_id',
2251
- });
2252
- return null;
2253
- }
2254
- }
2255
- if (role === 'assistant' && Array.isArray(m.tool_calls)) {
2256
- const toolCalls = m.tool_calls
2257
- .filter((tc) => typeof tc === 'object' &&
2258
- tc !== null &&
2259
- typeof tc.id === 'string' &&
2260
- tc.type === 'function' &&
2261
- typeof tc.function
2262
- ?.name === 'string' &&
2263
- typeof tc.function
2264
- ?.arguments === 'string')
2265
- .map((tc) => ({
2266
- id: tc.id,
2267
- type: 'function',
2268
- function: {
2269
- name: tc.function.name,
2270
- arguments: tc.function.arguments,
2271
- },
2272
- }));
2273
- if (toolCalls.length > 0) {
2274
- normalizedMessage.tool_calls = toolCalls;
2275
- if (!normalizedMessage.content)
2276
- normalizedMessage.content = null;
2277
- }
2278
- }
2279
- return normalizedMessage;
2280
- })
2281
- .filter((m) => m !== null);
2282
- // #171 (review#11): consume external (client-executed) tool result turns
2283
- // from the incoming history into a validated `extId → result` map and strip
2284
- // those raw turns from the messages forwarded to the agent (so no internal
2285
- // LLM call ever sees an unmatched assistant tool_calls). On a normal request
2286
- // with no external history this returns the messages unchanged + an empty
2287
- // map — a safe no-op. The map is threaded via options.externalResults.
2288
- const { results: externalResults, sanitizedMessages } = buildExternalResults(normalizedMessages);
2289
- const invalidToolsHeader = externalToolsValidation.errors.length > 0
2290
- ? {
2291
- 'x-smartagent-invalid-tools': String(externalToolsValidation.errors.length),
2292
- }
2293
- : {};
2294
- if (body.stream) {
2295
- res.writeHead(200, {
2296
- 'Content-Type': 'text/event-stream',
2297
- 'Cache-Control': 'no-cache',
2298
- Connection: 'keep-alive',
2299
- ...invalidToolsHeader,
2300
- });
2301
- const id = `chatcmpl-${randomUUID()}`;
2302
- const created = Math.floor(Date.now() / 1000);
2303
- const stream = smartAgent.streamProcess(sanitizedMessages, {
2304
- ...opts,
2305
- externalResults,
2306
- });
2307
- let firstChunk = true;
2308
- let finishReasonSent = false;
2309
- let lastUsage = null;
2310
- for await (const chunk of stream) {
2311
- if (!chunk.ok) {
2312
- const errorChunk = {
2313
- id,
2314
- object: 'chat.completion.chunk',
2315
- created,
2316
- model: responseModel,
2317
- choices: [
2318
- {
2319
- index: 0,
2320
- delta: { content: `[Error] ${chunk.error.message}` },
2321
- finish_reason: 'stop',
2322
- },
2323
- ],
2324
- };
2325
- res.write(`data: ${JSON.stringify(errorChunk)}\n\n`);
2326
- finishReasonSent = true;
2327
- break;
2328
- }
2329
- // SSE heartbeat comment — keeps connection alive, ignored by clients
2330
- if (chunk.value.heartbeat) {
2331
- const hb = chunk.value.heartbeat;
2332
- res.write(`: heartbeat tool=${hb.tool} elapsed=${hb.elapsed}ms\n\n`);
2333
- continue;
2334
- }
2335
- // SSE timing breakdown comment — sent with the final chunk
2336
- if (chunk.value.timing) {
2337
- const parts = chunk.value.timing.map((t) => `${t.phase}=${t.duration}ms`);
2338
- res.write(`: timing ${parts.join(' ')}\n\n`);
2339
- }
2340
- if (chunk.value.usage) {
2341
- lastUsage = {
2342
- prompt_tokens: chunk.value.usage.promptTokens,
2343
- completion_tokens: chunk.value.usage.completionTokens,
2344
- total_tokens: chunk.value.usage.totalTokens,
2345
- };
2346
- }
2347
- const baseResponse = {
2348
- id,
2349
- object: 'chat.completion.chunk',
2350
- created,
2351
- model: responseModel,
2352
- usage: null,
2353
- };
2354
- if (firstChunk) {
2355
- res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: { role: 'assistant', content: chunk.value.content || '' }, finish_reason: null }] })}\n\n`);
2356
- firstChunk = false;
2357
- if (!chunk.value.finishReason && !chunk.value.toolCalls)
2358
- continue;
2359
- }
2360
- if (chunk.value.content || chunk.value.toolCalls) {
2361
- const delta = {};
2362
- if (chunk.value.content)
2363
- delta.content = chunk.value.content;
2364
- if (chunk.value.toolCalls) {
2365
- delta.tool_calls = chunk.value.toolCalls.map((call, index) => {
2366
- const tc = toToolCallDelta(call, index);
2367
- return {
2368
- index: tc.index,
2369
- id: tc.id,
2370
- type: 'function',
2371
- function: {
2372
- name: tc.name,
2373
- arguments: tc.arguments || '',
2374
- },
2375
- };
2376
- });
2377
- }
2378
- 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;
2379
1692
  }
2380
- if (chunk.value.finishReason) {
2381
- res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: {}, finish_reason: mapStopReason(chunk.value.finishReason) }] })}\n\n`);
2382
- 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;
2383
1698
  }
2384
- }
2385
- if (!finishReasonSent) {
2386
- const baseResponse = {
2387
- id,
2388
- object: 'chat.completion.chunk',
2389
- created,
2390
- model: responseModel,
2391
- usage: null,
2392
- };
2393
- res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`);
2394
- }
2395
- if ((this.cfg.reportUsage !== false ||
2396
- body.stream_options?.include_usage) &&
2397
- lastUsage) {
2398
- res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model: responseModel, choices: [], usage: lastUsage })}\n\n`);
2399
- }
2400
- res.write('data: [DONE]\n\n');
2401
- res.end();
2402
- log({
2403
- event: 'request_done',
2404
- ok: true,
2405
- stream: true,
2406
- finishReason: finishReasonSent ? 'sent' : 'fallback_stop',
2407
- durationMs: Date.now() - t0,
2408
- });
2409
- return;
2410
- }
2411
- const result = await smartAgent.process(sanitizedMessages, {
2412
- ...opts,
2413
- externalResults,
2414
- });
2415
- log({ event: 'request_done', ok: result.ok, durationMs: Date.now() - t0 });
2416
- const finalContent = result.ok
2417
- ? result.value.content ||
2418
- (result.value.toolCalls ? null : '(no response)')
2419
- : `Error: ${result.error.message}`;
2420
- const finalFinishReason = result.ok
2421
- ? mapStopReason(result.value.stopReason)
2422
- : 'stop';
2423
- let finalUsage = null;
2424
- if (result.ok && result.value.usage) {
2425
- finalUsage = {
2426
- prompt_tokens: result.value.usage.promptTokens,
2427
- completion_tokens: result.value.usage.completionTokens,
2428
- total_tokens: result.value.usage.totalTokens,
2429
- };
2430
- }
2431
- const message = {
2432
- role: 'assistant',
2433
- content: finalContent,
2434
- };
2435
- if (result.ok && result.value.toolCalls) {
2436
- message.tool_calls = result.value.toolCalls;
2437
- }
2438
- res.writeHead(200, {
2439
- 'Content-Type': 'application/json',
2440
- ...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
+ },
2441
1703
  });
2442
- res.end(JSON.stringify({
2443
- id: `chatcmpl-${randomUUID()}`,
2444
- object: 'chat.completion',
2445
- created: Math.floor(Date.now() / 1000),
2446
- model: responseModel,
2447
- choices: [
2448
- {
2449
- index: 0,
2450
- message,
2451
- finish_reason: finalFinishReason,
2452
- },
2453
- ],
2454
- usage: finalUsage || {
2455
- prompt_tokens: 0,
2456
- completion_tokens: 0,
2457
- 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
+ });
2458
1716
  },
2459
- }));
1717
+ });
1718
+ return table;
2460
1719
  }
2461
- /** Whitelisted agent config fields allowed via PUT /v1/config. */
2462
- static AGENT_CONFIG_FIELDS = new Set([
2463
- 'maxIterations',
2464
- 'maxToolCalls',
2465
- 'ragQueryK',
2466
- 'toolUnavailableTtlMs',
2467
- 'showReasoning',
2468
- 'historyAutoSummarizeLimit',
2469
- 'classificationEnabled',
2470
- ]);
2471
- async _handleConfigUpdate(req, res, smartAgent) {
2472
- const raw = await readBody(req);
2473
- let parsed;
2474
- try {
2475
- parsed = JSON.parse(raw);
2476
- }
2477
- catch {
2478
- res.writeHead(400, { 'Content-Type': 'application/json' });
2479
- res.end(jsonError('Invalid JSON body', 'invalid_request_error'));
2480
- return;
2481
- }
2482
- if (typeof parsed !== 'object' ||
2483
- parsed === null ||
2484
- Array.isArray(parsed)) {
2485
- res.writeHead(400, { 'Content-Type': 'application/json' });
2486
- res.end(jsonError('Request body must be a JSON object', 'invalid_request_error'));
2487
- return;
2488
- }
2489
- const body = parsed;
2490
- // --- Validate agent fields against whitelist ---
2491
- if (body.agent !== undefined) {
2492
- if (typeof body.agent !== 'object' ||
2493
- body.agent === null ||
2494
- Array.isArray(body.agent)) {
2495
- res.writeHead(400, { 'Content-Type': 'application/json' });
2496
- res.end(jsonError('"agent" must be a JSON object', 'invalid_request_error'));
2497
- return;
2498
- }
2499
- const agentFields = body.agent;
2500
- const unsupported = Object.keys(agentFields).filter((k) => !SmartServer.AGENT_CONFIG_FIELDS.has(k));
2501
- if (unsupported.length > 0) {
2502
- res.writeHead(400, { 'Content-Type': 'application/json' });
2503
- res.end(jsonError(`Unsupported agent config fields: ${unsupported.join(', ')}`, 'invalid_request_error'));
2504
- return;
2505
- }
2506
- }
2507
- // --- Validate and resolve models (atomic: resolve ALL before mutating) ---
2508
- let resolvedModels;
2509
- if (body.models !== undefined) {
2510
- if (typeof body.models !== 'object' ||
2511
- body.models === null ||
2512
- Array.isArray(body.models)) {
2513
- res.writeHead(400, { 'Content-Type': 'application/json' });
2514
- res.end(jsonError('"models" must be a JSON object', 'invalid_request_error'));
2515
- return;
2516
- }
2517
- if (!this.cfg.modelResolver) {
2518
- res.writeHead(400, { 'Content-Type': 'application/json' });
2519
- res.end(jsonError('model resolver not configured', 'invalid_request_error'));
2520
- return;
2521
- }
2522
- const modelFields = body.models;
2523
- const validKeys = new Set([
2524
- 'mainModel',
2525
- 'classifierModel',
2526
- 'helperModel',
2527
- ]);
2528
- const unknownKeys = Object.keys(modelFields).filter((k) => !validKeys.has(k));
2529
- if (unknownKeys.length > 0) {
2530
- res.writeHead(400, { 'Content-Type': 'application/json' });
2531
- res.end(jsonError(`Unknown model fields: ${unknownKeys.join(', ')}`, 'invalid_request_error'));
2532
- return;
2533
- }
2534
- try {
2535
- const resolver = this.cfg.modelResolver;
2536
- const [mainLlm, classifierLlm, helperLlm] = await Promise.all([
2537
- modelFields.mainModel
2538
- ? resolver.resolve(String(modelFields.mainModel), 'main')
2539
- : undefined,
2540
- modelFields.classifierModel
2541
- ? resolver.resolve(String(modelFields.classifierModel), 'classifier')
2542
- : undefined,
2543
- modelFields.helperModel
2544
- ? resolver.resolve(String(modelFields.helperModel), 'helper')
2545
- : undefined,
2546
- ]);
2547
- resolvedModels = {};
2548
- if (mainLlm)
2549
- resolvedModels.mainLlm = mainLlm;
2550
- if (classifierLlm)
2551
- resolvedModels.classifierLlm = classifierLlm;
2552
- if (helperLlm)
2553
- resolvedModels.helperLlm = helperLlm;
2554
- }
2555
- catch (err) {
2556
- res.writeHead(500, { 'Content-Type': 'application/json' });
2557
- res.end(jsonError(String(err), 'server_error'));
2558
- return;
2559
- }
2560
- }
2561
- // --- All validation passed — apply mutations ---
2562
- if (resolvedModels) {
2563
- smartAgent.reconfigure(resolvedModels);
2564
- // Mirror onto the hoisted globals consumed by `buildSessionAgent` so
2565
- // freshly-built session graphs pick up the new LLMs by reference
2566
- // (otherwise `this._mainLlm` etc. would keep pointing at the originals
2567
- // captured during `start()`).
2568
- if (resolvedModels.mainLlm)
2569
- this._mainLlm = resolvedModels.mainLlm;
2570
- if (resolvedModels.classifierLlm)
2571
- this._classifierLlm = resolvedModels.classifierLlm;
2572
- if (resolvedModels.helperLlm)
2573
- this._helperLlm = resolvedModels.helperLlm;
2574
- }
2575
- if (body.agent) {
2576
- const patch = body.agent;
2577
- smartAgent.applyConfigUpdate(patch);
2578
- // Mirror onto `this.cfg.agent` so freshly-built session graphs (which
2579
- // read `this.cfg.agent` in `buildSessionAgent`) observe the update.
2580
- // Deep-merge to preserve untouched startup fields; replacing the whole
2581
- // `agent` block would drop YAML defaults the validator already applied.
2582
- const merged = {
2583
- ...(this.cfg.agent ?? {}),
2584
- ...patch,
2585
- };
2586
- this.cfg.agent = merged;
2587
- }
2588
- // Invalidate per-session SmartAgents + the worker-LLM cache so the next
2589
- // request mints a session graph that observes the just-applied config.
2590
- // Without this, chat routes dispatch to `graph.agent` (the per-session
2591
- // SmartAgent) which was built with the OLD config, and the PUT is a
2592
- // no-op from the consumer's perspective. Failures are non-fatal so the
2593
- // 200 response isn't blocked by a dispose hiccup.
2594
- if (resolvedModels || body.agent) {
2595
- // Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing the
2596
- // cache so MCP clients owned by the discarded handles disconnect.
2597
- await drainWorkerCache(this._workerLlmCache);
2598
- try {
2599
- await this._lifecycle?.invalidateAll();
2600
- }
2601
- catch {
2602
- // Swallow: cleanup errors must not turn a successful config update
2603
- // into a 500. The next request will still get a fresh build because
2604
- // `_workerLlmCache` is already cleared and dispose is idempotent.
2605
- }
2606
- }
2607
- // --- Return updated config ---
2608
- const models = smartAgent.getActiveConfig();
2609
- const agent = smartAgent.getAgentConfig();
2610
- res.writeHead(200, { 'Content-Type': 'application/json' });
2611
- 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
+ };
2612
1749
  }
2613
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
+ }
2614
1758
  //# sourceMappingURL=smart-server.js.map