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