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