@mcp-abap-adt/llm-agent-server-libs 20.0.0 → 20.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/factories/controller-factory.d.ts +18 -1
- package/dist/factories/controller-factory.d.ts.map +1 -1
- package/dist/factories/controller-factory.js +12 -1
- package/dist/factories/controller-factory.js.map +1 -1
- 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/mcp/compose-auxiliary.d.ts +35 -0
- package/dist/mcp/compose-auxiliary.d.ts.map +1 -0
- package/dist/mcp/compose-auxiliary.js +64 -0
- package/dist/mcp/compose-auxiliary.js.map +1 -0
- package/dist/pipelines/controller.d.ts.map +1 -1
- package/dist/pipelines/controller.js +87 -5
- 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 +24 -50
- package/dist/smart-agent/controller/controller-coordinator-handler.d.ts.map +1 -1
- package/dist/smart-agent/controller/controller-coordinator-handler.js +542 -676
- package/dist/smart-agent/controller/controller-coordinator-handler.js.map +1 -1
- package/dist/smart-agent/controller/default-step-execution-control.d.ts +7 -0
- package/dist/smart-agent/controller/default-step-execution-control.d.ts.map +1 -0
- package/dist/smart-agent/controller/default-step-execution-control.js +36 -0
- package/dist/smart-agent/controller/default-step-execution-control.js.map +1 -0
- 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/noop-run-execution-control.d.ts +6 -0
- package/dist/smart-agent/controller/noop-run-execution-control.d.ts.map +1 -0
- package/dist/smart-agent/controller/noop-run-execution-control.js +14 -0
- package/dist/smart-agent/controller/noop-run-execution-control.js.map +1 -0
- 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/subagent-client.d.ts +2 -2
- package/dist/smart-agent/controller/subagent-client.d.ts.map +1 -1
- package/dist/smart-agent/controller/subagent-client.js +2 -2
- package/dist/smart-agent/controller/subagent-client.js.map +1 -1
- package/dist/smart-agent/controller/types.d.ts +16 -4
- package/dist/smart-agent/controller/types.d.ts.map +1 -1
- package/dist/smart-agent/controller/types.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 +169 -222
- package/dist/smart-agent/smart-server.d.ts.map +1 -1
- package/dist/smart-agent/smart-server.js +569 -1387
- 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, WindowContextStrategy, } from '@mcp-abap-adt/llm-agent-libs';
|
|
11
|
+
import { DefaultMcpFailureClassifier, MCPClientWrapper, McpClientAdapter, } from '@mcp-abap-adt/llm-agent-mcp';
|
|
12
12
|
import { makeRag, prefetchEmbedderFactories, resolveEmbedder, } from '@mcp-abap-adt/llm-agent-rag';
|
|
13
13
|
import { PACKAGE_VERSION } from '../generated/version.js';
|
|
14
|
+
import { ConfigReloadWatcher } from './config-reload-watcher.js';
|
|
15
|
+
import { handleAdapterRequest } from './http/adapter-route-handler.js';
|
|
16
|
+
import { handleChat } from './http/chat-route-handler.js';
|
|
17
|
+
import { handleConfigUpdate, } from './http/config-route-handler.js';
|
|
18
|
+
import { handleHealthRoute } from './http/health-route-handler.js';
|
|
19
|
+
import { handleEmbeddingModelsList, handleModelsList, } from './http/models-route-handler.js';
|
|
20
|
+
import { CORS_HEADERS, jsonError, writeNotReady, } from './http/response-helpers.js';
|
|
21
|
+
import { HttpRouteTable } from './http/route-table.js';
|
|
22
|
+
import { handleSessionDelete, handleSessionResume, handleSessionsList, } from './http/sessions-route-handler.js';
|
|
23
|
+
import { handleUsageRoute } from './http/usage-route-handler.js';
|
|
24
|
+
import { makeDefaultRoleLlm, RoleLlmResolver, } from './llm/role-llm-resolver.js';
|
|
14
25
|
import { resolveAgentEmbedder } from './resolve-agent-embedder.js';
|
|
15
|
-
import {
|
|
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,33 @@ export async function connectMcpClientsFromConfig(mcpCfg) {
|
|
|
399
130
|
}
|
|
400
131
|
return connected;
|
|
401
132
|
}
|
|
402
|
-
export function buildMcpBridge(clients) {
|
|
403
|
-
return async (name, args,
|
|
133
|
+
export function buildMcpBridge(clients, classifier = new DefaultMcpFailureClassifier()) {
|
|
134
|
+
return async (name, args, signal) => {
|
|
404
135
|
const safeArgs = args != null && typeof args === 'object' && !Array.isArray(args)
|
|
405
136
|
? args
|
|
406
137
|
: {};
|
|
138
|
+
const opts = signal ? { signal } : undefined;
|
|
407
139
|
for (const client of clients) {
|
|
408
|
-
const
|
|
409
|
-
|
|
140
|
+
const probe = client.healthCheck
|
|
141
|
+
? () => client.healthCheck(opts).then((r) => (r.ok ? r.value : false))
|
|
142
|
+
: undefined;
|
|
143
|
+
const listed = await client.listTools(opts);
|
|
144
|
+
if (!listed.ok) {
|
|
145
|
+
// FAIL LOUD on an availability failure: a transient listTools() outage must
|
|
146
|
+
// NOT make the tool look merely absent (→ "Tool not found"/tool-blind). A
|
|
147
|
+
// benign error (this client genuinely can't list) falls through to the next.
|
|
148
|
+
if ((await classifier.classify(listed.error, probe)) === 'unavailable')
|
|
149
|
+
throw listed.error;
|
|
410
150
|
continue;
|
|
151
|
+
}
|
|
411
152
|
const owns = listed.value.some((t) => t.name === name);
|
|
412
153
|
if (!owns)
|
|
413
154
|
continue;
|
|
414
|
-
const result = await client.callTool(name, safeArgs);
|
|
155
|
+
const result = await client.callTool(name, safeArgs, opts);
|
|
415
156
|
if (!result.ok) {
|
|
157
|
+
// Availability failure → fail loud; a tool-level error → LLM feedback text.
|
|
158
|
+
if ((await classifier.classify(result.error, probe)) === 'unavailable')
|
|
159
|
+
throw result.error;
|
|
416
160
|
return result.error.message;
|
|
417
161
|
}
|
|
418
162
|
const { content } = result.value;
|
|
@@ -425,11 +169,17 @@ export class SmartServer {
|
|
|
425
169
|
cfg;
|
|
426
170
|
noop = () => { };
|
|
427
171
|
/**
|
|
428
|
-
* GLOBAL per-worker LLM/embedder cache. Populated lazily by `buildSubAgent`
|
|
172
|
+
* GLOBAL per-worker LLM/embedder cache registry. Populated lazily by `buildSubAgent`
|
|
429
173
|
* the first time each worker name is seen; subsequent per-session re-wires
|
|
430
|
-
* pull from
|
|
174
|
+
* pull from the cache by reference (never reconstructing LLM clients).
|
|
175
|
+
* Constructed in `_buildInfra` after embedder factories are resolved.
|
|
176
|
+
*/
|
|
177
|
+
_workers;
|
|
178
|
+
/**
|
|
179
|
+
* Declarative HTTP route table built once; `_handle` delegates to its
|
|
180
|
+
* `dispatch`. Replaces the former ~300-line if/else route chain.
|
|
431
181
|
*/
|
|
432
|
-
|
|
182
|
+
_routeTable = this._buildRouteTable();
|
|
433
183
|
/** Lifecycle handle wired in `start()`; consumed by `_handle`. */
|
|
434
184
|
_lifecycle;
|
|
435
185
|
/** Hoisted globals used by `buildSessionAgent` to re-wire fresh per-session workers. */
|
|
@@ -464,6 +214,7 @@ export class SmartServer {
|
|
|
464
214
|
_llmMap;
|
|
465
215
|
_pipelineFallback;
|
|
466
216
|
_mainTemp;
|
|
217
|
+
_roleLlm;
|
|
467
218
|
_requestLogger;
|
|
468
219
|
/** ToolsRag handle built by `buildSharedPipelineInfra`; handed to every
|
|
469
220
|
* pipeline's context (factory defaults to EMPTY_TOOLS_RAG if unset). */
|
|
@@ -486,6 +237,15 @@ export class SmartServer {
|
|
|
486
237
|
* yaml). Disposed via the server's `closeFns` on shutdown.
|
|
487
238
|
*/
|
|
488
239
|
_stepperMcpClients;
|
|
240
|
+
/**
|
|
241
|
+
* True when the consumer injected an MCP seam (`BuildAgentDeps.mcpClients` or
|
|
242
|
+
* `connectMcp`). In that case MCP is provisioned ONLY through the seam (the
|
|
243
|
+
* embeddable path must never force a real connect / builder self-connect). When
|
|
244
|
+
* false (default), the YAML `mcp:` path keeps the builder-owned connect so the
|
|
245
|
+
* builder VECTORIZES the tools into `toolsRag` (the ToolSelect ranking contract;
|
|
246
|
+
* see mcp-yaml-vectorization.test.ts).
|
|
247
|
+
*/
|
|
248
|
+
_mcpSeamInjected;
|
|
489
249
|
/**
|
|
490
250
|
* The MCP clients the pipeline `callMcp` bridge dispatches over — resolved
|
|
491
251
|
* UNCONDITIONALLY in `start()` as DI/plugin clients (`mcpClients`) ?? the
|
|
@@ -520,8 +280,50 @@ export class SmartServer {
|
|
|
520
280
|
* handles owned by the plugin) are freed on eviction / shutdown / reconfigure.
|
|
521
281
|
*/
|
|
522
282
|
_sessionCloseFns = new Map();
|
|
523
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Instance-level MCP failure classifier (DI/programmatic only — not from YAML).
|
|
285
|
+
* Populated from BuildAgentDeps in the constructor; defaults to DefaultMcpFailureClassifier.
|
|
286
|
+
* Passed to buildMcpBridge (Route B) and threaded into every pipeline ctx (Route A).
|
|
287
|
+
*/
|
|
288
|
+
_mcpFailureClassifier;
|
|
289
|
+
_toolLoopContextStrategyFactory;
|
|
290
|
+
_stepExecutionControl;
|
|
291
|
+
_runExecutionControl;
|
|
292
|
+
_auxiliaryMcpTools;
|
|
293
|
+
/**
|
|
294
|
+
* Defaulted construction deps (the BuildAgentDeps DI seam). Required members
|
|
295
|
+
* always resolve to the real implementation when not injected; `skillHost`
|
|
296
|
+
* and `embedder` stay optional (present only when injected).
|
|
297
|
+
*/
|
|
298
|
+
_deps;
|
|
299
|
+
constructor(config, deps = {}) {
|
|
524
300
|
this.cfg = config;
|
|
301
|
+
this._mcpSeamInjected =
|
|
302
|
+
deps.mcpClients !== undefined || deps.connectMcp !== undefined;
|
|
303
|
+
this._mcpFailureClassifier =
|
|
304
|
+
deps.mcpFailureClassifier ?? new DefaultMcpFailureClassifier();
|
|
305
|
+
// The DI seam carries the CONSUMER-injected factory ONLY (undefined when not
|
|
306
|
+
// injected). It is threaded verbatim onto the pipeline ctx so a consumer
|
|
307
|
+
// override wins on EVERY pipeline — including the controller, which resolves
|
|
308
|
+
// `ctx.toolLoopContextStrategyFactory ?? <its own RagRecall>`. The server's
|
|
309
|
+
// Window default for the NON-controller pipelines is applied ONLY on the
|
|
310
|
+
// builder channel (buildBaseBuilder), so it never leaks into the controller's
|
|
311
|
+
// ctx read. A bare library consumer of DefaultPipeline/SmartAgent (no
|
|
312
|
+
// SmartServer) still falls back to Legacy at point-of-use.
|
|
313
|
+
this._toolLoopContextStrategyFactory = deps.toolLoopContextStrategyFactory;
|
|
314
|
+
this._stepExecutionControl = deps.stepExecutionControl;
|
|
315
|
+
this._runExecutionControl = deps.runExecutionControl;
|
|
316
|
+
this._auxiliaryMcpTools = deps.auxiliaryMcpTools;
|
|
317
|
+
this._deps = {
|
|
318
|
+
makeLlm: deps.makeLlm ?? ((cfg) => this._makeLlmDefault(cfg)),
|
|
319
|
+
resolveEmbedder: deps.resolveEmbedder ?? resolveEmbedder,
|
|
320
|
+
prefetchEmbedderFactories: deps.prefetchEmbedderFactories ?? prefetchEmbedderFactories,
|
|
321
|
+
buildSkillHost: deps.buildSkillHost ?? buildSkillHostFromConfig,
|
|
322
|
+
connectMcp: deps.connectMcp ?? connectMcpClientsFromConfig,
|
|
323
|
+
...(deps.skillHost ? { skillHost: deps.skillHost } : {}),
|
|
324
|
+
...(deps.embedder ? { embedder: deps.embedder } : {}),
|
|
325
|
+
...(deps.mcpClients ? { mcpClients: deps.mcpClients } : {}),
|
|
326
|
+
};
|
|
525
327
|
}
|
|
526
328
|
async start() {
|
|
527
329
|
// Startup pg-pool cleanup must span the ENTIRE start(): host.load() (via
|
|
@@ -547,7 +349,17 @@ export class SmartServer {
|
|
|
547
349
|
}
|
|
548
350
|
}
|
|
549
351
|
}
|
|
550
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Assemble and return the server INFRA bundle ONLY — every shared resource
|
|
354
|
+
* needed by both the HTTP `_start()` path and the embeddable
|
|
355
|
+
* `_buildEmbeddedAgent()` path: the infra/passthrough `smartAgent`, the
|
|
356
|
+
* server-only locals (`chat`/`streamChat`/`requestLogger`/etc.), the resolved
|
|
357
|
+
* `globalMcpClients`/`globalRagRegistry`, and the `closeFns` loop (exposed as
|
|
358
|
+
* `close`). It does NOT build the `'embedded'` pipeline instance — that idle
|
|
359
|
+
* coordinator is built only on the embeddable path (see `_buildEmbeddedAgent`),
|
|
360
|
+
* so a plain `start()` no longer pays for a coordinator it never serves.
|
|
361
|
+
*/
|
|
362
|
+
async _buildInfra() {
|
|
551
363
|
const log = this.cfg.log ?? this.noop;
|
|
552
364
|
const fileLogger = {
|
|
553
365
|
log: (e) => log(e),
|
|
@@ -564,23 +376,13 @@ export class SmartServer {
|
|
|
564
376
|
const topMain = resolveLlmConfig(llmMap, 'main', pipelineFallback);
|
|
565
377
|
const mainTemp = Number(topMain?.temperature ?? 0.7);
|
|
566
378
|
const mainLlm = topMain
|
|
567
|
-
? await makeLlm({
|
|
568
|
-
provider: topMain.provider ?? 'deepseek',
|
|
569
|
-
apiKey: topMain.apiKey,
|
|
570
|
-
baseURL: topMain.url,
|
|
571
|
-
model: topMain.model,
|
|
572
|
-
}, mainTemp)
|
|
379
|
+
? await this._deps.makeLlm({ ...topMain, temperature: mainTemp })
|
|
573
380
|
: (() => {
|
|
574
381
|
throw new Error('no LLM configured: provide top-level llm.main');
|
|
575
382
|
})();
|
|
576
383
|
const classifierTemp = Number(topMain?.classifierTemperature ?? 0.1);
|
|
577
384
|
const classifierLlm = topMain
|
|
578
|
-
? await makeLlm({
|
|
579
|
-
provider: topMain.provider ?? 'deepseek',
|
|
580
|
-
apiKey: topMain.apiKey,
|
|
581
|
-
baseURL: topMain.url,
|
|
582
|
-
model: topMain.model,
|
|
583
|
-
}, classifierTemp)
|
|
385
|
+
? await this._deps.makeLlm({ ...topMain, temperature: classifierTemp })
|
|
584
386
|
: (() => {
|
|
585
387
|
throw new Error('no LLM configured: provide top-level llm.main');
|
|
586
388
|
})();
|
|
@@ -588,12 +390,10 @@ export class SmartServer {
|
|
|
588
390
|
// (built only when an explicit map entry exists).
|
|
589
391
|
const helperCfg = resolveLlmConfigStrict(llmMap, 'helper');
|
|
590
392
|
const helperLlm = helperCfg
|
|
591
|
-
? await makeLlm({
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
model: helperCfg.model,
|
|
596
|
-
}, Number(helperCfg.temperature ?? 0.1))
|
|
393
|
+
? await this._deps.makeLlm({
|
|
394
|
+
...helperCfg,
|
|
395
|
+
temperature: Number(helperCfg.temperature ?? 0.1),
|
|
396
|
+
})
|
|
597
397
|
: undefined;
|
|
598
398
|
this._mainLlm = mainLlm;
|
|
599
399
|
this._classifierLlm = classifierLlm;
|
|
@@ -601,6 +401,14 @@ export class SmartServer {
|
|
|
601
401
|
this._llmMap = llmMap;
|
|
602
402
|
this._pipelineFallback = pipelineFallback;
|
|
603
403
|
this._mainTemp = mainTemp;
|
|
404
|
+
this._roleLlm = new RoleLlmResolver({
|
|
405
|
+
getMain: () => this._mainLlm,
|
|
406
|
+
getHelper: () => this._helperLlm,
|
|
407
|
+
getClassifier: () => this._classifierLlm,
|
|
408
|
+
getLlmMap: () => this._llmMap,
|
|
409
|
+
getPipelineFallback: () => this._pipelineFallback,
|
|
410
|
+
makeLlm: (lc) => this._deps.makeLlm(lc),
|
|
411
|
+
});
|
|
604
412
|
// ---- Plugin loader -------------------------------------------------------
|
|
605
413
|
const pluginLoader = this.cfg.pluginLoader ??
|
|
606
414
|
(() => {
|
|
@@ -682,9 +490,19 @@ export class SmartServer {
|
|
|
682
490
|
...this.cfg.embedderFactories, // config takes precedence over plugins
|
|
683
491
|
};
|
|
684
492
|
this._mergedEmbedderFactories = mergedEmbedderFactories;
|
|
493
|
+
// Construct the WorkerRegistry (owns the per-worker LLM cache + build loop).
|
|
494
|
+
// Both _fileLogger (set at the top of _buildInfra) and _mergedEmbedderFactories
|
|
495
|
+
// (set above) are captured lazily via the accessor callbacks, so construction
|
|
496
|
+
// here precedes the first use of this._workers.
|
|
497
|
+
this._workers = new WorkerRegistry({
|
|
498
|
+
subAgentConfigs: this.cfg.subAgentConfigs,
|
|
499
|
+
getFileLogger: () => this._fileLogger,
|
|
500
|
+
getEmbedderFactories: () => this._mergedEmbedderFactories ?? {},
|
|
501
|
+
buildSubAgent: (name, subCfg, parentLogger, factories, injected) => this.buildSubAgent(name, subCfg, parentLogger, factories, injected),
|
|
502
|
+
});
|
|
685
503
|
// Resolve the embedder ONCE so the same instance feeds both makeRag and the
|
|
686
504
|
// subagent context-builder's toolSource (#137). See resolve-agent-embedder.
|
|
687
|
-
const resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this.cfg.embedder, mergedEmbedderFactories);
|
|
505
|
+
const resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this._deps.embedder ?? this.cfg.embedder, mergedEmbedderFactories);
|
|
688
506
|
// Hold the resolved embedder so buildServerCtx can thread it onto every
|
|
689
507
|
// pipeline context (the controller pipeline needs it for target-state).
|
|
690
508
|
this._resolvedEmbedder = resolvedEmbedder;
|
|
@@ -695,11 +513,15 @@ export class SmartServer {
|
|
|
695
513
|
// llm-agent-rag). Absent `skillPlugins:` → no host, behaviour unchanged.
|
|
696
514
|
if (this.cfg.skillPlugins) {
|
|
697
515
|
const skillCfg = this.cfg.skillPlugins;
|
|
698
|
-
|
|
516
|
+
// An injected embedder short-circuits ALL embedder I/O for the skill host
|
|
517
|
+
// (no dedicated build, no prefetch) — the seam owns the embedder.
|
|
518
|
+
const injectedEmbedder = this._deps.embedder;
|
|
519
|
+
const reuseAgentEmbedder = injectedEmbedder !== undefined ||
|
|
520
|
+
(skillCfg.embedder === undefined && resolvedEmbedder !== undefined);
|
|
699
521
|
// Prefetch the named embedder factory only when we will actually build a
|
|
700
522
|
// dedicated one (the agent embedder is already prefetched + wrapped).
|
|
701
523
|
if (!reuseAgentEmbedder) {
|
|
702
|
-
await prefetchEmbedderFactories([
|
|
524
|
+
await this._deps.prefetchEmbedderFactories([
|
|
703
525
|
skillCfg.embedder?.provider ?? 'ollama',
|
|
704
526
|
]);
|
|
705
527
|
}
|
|
@@ -707,33 +529,36 @@ export class SmartServer {
|
|
|
707
529
|
// captured pg pools are ended INSIDE initSkillHost (the later closeFns
|
|
708
530
|
// cleanup never runs when start() rejects before returning a handle), so
|
|
709
531
|
// the pools cannot leak open sockets on a startup failure.
|
|
710
|
-
|
|
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
|
-
|
|
532
|
+
const buildHost = this._deps.skillHost
|
|
533
|
+
? async () => this._deps.skillHost
|
|
534
|
+
: () => this._deps.buildSkillHost(skillCfg, {
|
|
535
|
+
resolveEmbedder: (ec) => reuseAgentEmbedder
|
|
536
|
+
? (injectedEmbedder ?? resolvedEmbedder)
|
|
537
|
+
: this._deps.resolveEmbedder(ec, {
|
|
538
|
+
extraFactories: mergedEmbedderFactories,
|
|
539
|
+
}),
|
|
540
|
+
// Real pg `Pool` provider for a `postgres` catalog (qdrant
|
|
541
|
+
// deployment). Lazily imports `pg` and ensures the catalog table
|
|
542
|
+
// exists on first use; pass the configured table so the DDL targets
|
|
543
|
+
// the SAME table the catalog store reads/writes. Absent
|
|
544
|
+
// skillPlugins.catalog.type:postgres this is never invoked.
|
|
545
|
+
makePgPool: (connectionString) => {
|
|
546
|
+
const pool = makePgPool(connectionString, skillCfg.catalog.type === 'postgres'
|
|
547
|
+
? skillCfg.catalog.table
|
|
548
|
+
: undefined);
|
|
549
|
+
this._skillPgPools.push(pool);
|
|
550
|
+
return pool;
|
|
551
|
+
},
|
|
552
|
+
// READ-ONLY pg pool for the recall-only path — NEVER runs DDL, so a
|
|
553
|
+
// recall-only process with read-only pg credentials does not crash
|
|
554
|
+
// attempting to CREATE the catalog table it only reads.
|
|
555
|
+
makePgReadPool: (connectionString) => {
|
|
556
|
+
const pool = makePgReadPool(connectionString);
|
|
557
|
+
this._skillPgPools.push(pool);
|
|
558
|
+
return pool;
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
this._skillHost = await initSkillHost(buildHost, skillCfg, this._skillPgPools);
|
|
737
562
|
}
|
|
738
563
|
// ---- RAG resolution (interface-only) ----------------------------------
|
|
739
564
|
// Resolve the tools/history stores and any named collections HERE so the
|
|
@@ -758,9 +583,12 @@ export class SmartServer {
|
|
|
758
583
|
// block above is the single source of truth for the tools/history stores.
|
|
759
584
|
// Deployments that previously declared `pipeline.rag.{name}` collections must
|
|
760
585
|
// move them to top-level `rag:` (or register them as plugin RAG).
|
|
761
|
-
// MCP clients (DI > plugin > YAML).
|
|
762
|
-
//
|
|
763
|
-
|
|
586
|
+
// MCP clients (BuildAgentDeps.mcpClients > DI cfg.mcpClients > plugin > YAML).
|
|
587
|
+
// P1b: `this._deps.mcpClients` is the embeddable seam's ready-client override —
|
|
588
|
+
// when present it short-circuits ALL connect paths (parallel to skillHost). The
|
|
589
|
+
// YAML `mcp:` block is otherwise NOT pre-connected here — see the branch below.
|
|
590
|
+
const diOrPluginMcpClients = this._deps.mcpClients ??
|
|
591
|
+
this.cfg.mcpClients ??
|
|
764
592
|
(plugins.mcpClients.length > 0 ? plugins.mcpClients : undefined);
|
|
765
593
|
// ---- Knowledge backend (no MCP dependency) ----------------------------
|
|
766
594
|
// The remaining shared pipeline infra (`_sharedMcpClients` + the
|
|
@@ -769,48 +597,65 @@ export class SmartServer {
|
|
|
769
597
|
// connects + vectorizes (the builder owns that single connection).
|
|
770
598
|
this.buildKnowledgeBackend();
|
|
771
599
|
// ---- MCP connection strategy (exactly ONE connection) -----------------
|
|
772
|
-
//
|
|
773
|
-
//
|
|
600
|
+
// P1b: MCP is ALWAYS provisioned through the injected `BuildAgentDeps` seam so
|
|
601
|
+
// the embeddable `buildAgent(cfg)` path never forces a real connect. Two
|
|
602
|
+
// sources, ONE provisioning point:
|
|
603
|
+
//
|
|
604
|
+
// • Ready clients present (`_deps.mcpClients` / `cfg.mcpClients` / plugin
|
|
605
|
+
// clients, captured as `diOrPluginMcpClients`) → inject them into the
|
|
606
|
+
// startup builder via `withMcpClients` (builder short-circuits its own
|
|
607
|
+
// `cfg.mcp` auto-connect). `_sharedMcpClients` = that exact set, and the
|
|
608
|
+
// `_toolsRagHandle` catalog is built now over them.
|
|
774
609
|
//
|
|
775
|
-
// •
|
|
776
|
-
// `
|
|
777
|
-
//
|
|
778
|
-
// builder
|
|
779
|
-
//
|
|
610
|
+
// • No ready clients but a YAML `mcp:` block → provision ONCE via the seam
|
|
611
|
+
// (`this._deps.connectMcp(this.cfg.mcp)`; default = real connect, an
|
|
612
|
+
// embedded host can inject a stub) and inject those clients too, so the
|
|
613
|
+
// builder does NOT self-connect from `cfg.mcp` (single provisioning
|
|
614
|
+
// point). The `_toolsRagHandle` catalog is built over the connected set.
|
|
615
|
+
// (Tool ranking falls back to the MCP catalog, same as the ready-client
|
|
616
|
+
// path; the builder no longer vectorizes the YAML `mcp:` block itself.)
|
|
780
617
|
//
|
|
781
|
-
// •
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
// length. (`diOrPluginMcpClients` is already undefined when neither DI nor a
|
|
795
|
-
// non-empty plugin set was provided — see its resolution above.)
|
|
796
|
-
const hasDiOrPlugin = diOrPluginMcpClients !== undefined;
|
|
618
|
+
// • No clients and no `mcp:` block → undefined; no MCP wiring at all.
|
|
619
|
+
// Precedence: a ready client set (even an EMPTY array) overrides YAML `mcp:`.
|
|
620
|
+
// `cfg.mcpClients: []` (or `_deps.mcpClients: []`) is a deliberate "disable
|
|
621
|
+
// MCP / override plugin+YAML" signal — it takes the inject branch (inject `[]`
|
|
622
|
+
// → builder short-circuits → no YAML connect), NOT the YAML connect branch. So
|
|
623
|
+
// gate on presence (`!== undefined`), not length.
|
|
624
|
+
const hasReadyClients = diOrPluginMcpClients !== undefined;
|
|
625
|
+
// YAML `mcp:` with NO ready clients AND NO injected seam → keep the legacy
|
|
626
|
+
// builder-owned connect so the builder VECTORIZES the tools (the ToolSelect
|
|
627
|
+
// ranking contract). `_sharedMcpClients` + the tools-RAG handle are harvested
|
|
628
|
+
// from the built handle AFTER `build()` (see the harvest block below). When
|
|
629
|
+
// the seam IS injected we provision through it instead (no builder connect).
|
|
630
|
+
const yamlBuilderConnect = !hasReadyClients && !!this.cfg.mcp && !this._mcpSeamInjected;
|
|
797
631
|
let mcpClients;
|
|
798
|
-
if (
|
|
799
|
-
// DI/plugin branch — resolve `_sharedMcpClients` + tools-RAG handle NOW
|
|
800
|
-
// (knowledge backend is idempotent; already built above).
|
|
801
|
-
await this.buildSharedPipelineInfra({
|
|
802
|
-
toolsRag,
|
|
803
|
-
resolvedEmbedder,
|
|
804
|
-
mcpClients: diOrPluginMcpClients,
|
|
805
|
-
});
|
|
632
|
+
if (hasReadyClients) {
|
|
806
633
|
mcpClients = diOrPluginMcpClients;
|
|
807
634
|
}
|
|
635
|
+
else if (this.cfg.mcp && this._mcpSeamInjected) {
|
|
636
|
+
// Injected seam + YAML `mcp:` → the seam is the SINGLE provisioning point
|
|
637
|
+
// (the embeddable path must never force a real connect). Stash on
|
|
638
|
+
// `_stepperMcpClients` so the idempotent guard inside
|
|
639
|
+
// buildSharedPipelineInfra does not connect a second time.
|
|
640
|
+
this._stepperMcpClients = await this._deps.connectMcp(this.cfg.mcp);
|
|
641
|
+
mcpClients = this._stepperMcpClients;
|
|
642
|
+
}
|
|
808
643
|
else {
|
|
809
|
-
//
|
|
810
|
-
// `cfg.mcp
|
|
811
|
-
// the built handle AFTER `build()` (see below).
|
|
644
|
+
// No MCP, or the YAML-builder-connect path (mcpClients stays undefined so
|
|
645
|
+
// the builder receives `cfg.mcp` and connects + vectorizes itself).
|
|
812
646
|
mcpClients = undefined;
|
|
813
647
|
}
|
|
648
|
+
// Resolve `_sharedMcpClients` + the tools-RAG handle catalog over the
|
|
649
|
+
// provisioned set for every path EXCEPT yamlBuilderConnect, which resolves
|
|
650
|
+
// them AFTER `build()` from the harvested handle (knowledge backend is
|
|
651
|
+
// idempotent; already built above).
|
|
652
|
+
if (!yamlBuilderConnect) {
|
|
653
|
+
await this.buildSharedPipelineInfra({
|
|
654
|
+
toolsRag,
|
|
655
|
+
resolvedEmbedder,
|
|
656
|
+
mcpClients,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
814
659
|
// Build SubAgentRegistry from `subagents:` YAML block (if present).
|
|
815
660
|
// Each sub-agent is a minimal SmartAgent reusing the parent's plugin
|
|
816
661
|
// outputs (embedder factories, plugins) but with its own LLM/RAG/MCP/etc.
|
|
@@ -861,13 +706,14 @@ export class SmartServer {
|
|
|
861
706
|
const agentHandle = await builder.build();
|
|
862
707
|
const { agent: smartAgent, chat, streamChat, close: closeAgent, circuitBreakers, ragStores, modelProvider, } = agentHandle;
|
|
863
708
|
const { ragRegistry: globalRagRegistry, mcpClients: globalMcpClients } = agentHandle;
|
|
864
|
-
// ---- YAML-
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
// the SAME single connection (no
|
|
869
|
-
//
|
|
870
|
-
|
|
709
|
+
// ---- YAML-builder-connect MCP harvest (single-connect + vectorization) ----
|
|
710
|
+
// Only on the `yamlBuilderConnect` path (YAML `mcp:`, no ready clients, no
|
|
711
|
+
// injected seam) did the startup builder OWN the connection AND vectorize the
|
|
712
|
+
// tools into `toolsRag`. Harvest its connected set into `_sharedMcpClients` so
|
|
713
|
+
// `ctx.callMcp` + per-session agents reuse the SAME single connection (no
|
|
714
|
+
// second connect), then build the tools-RAG handle catalog over it. Every
|
|
715
|
+
// other path already resolved these in buildSharedPipelineInfra before build().
|
|
716
|
+
if (yamlBuilderConnect) {
|
|
871
717
|
this._sharedMcpClients = globalMcpClients ?? [];
|
|
872
718
|
await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
|
|
873
719
|
}
|
|
@@ -942,7 +788,7 @@ export class SmartServer {
|
|
|
942
788
|
// worker-owned MCP clients themselves disconnect. Ordering matters —
|
|
943
789
|
// closing MCP clients while a session graph is mid-use would cut its
|
|
944
790
|
// request short.
|
|
945
|
-
await
|
|
791
|
+
await this._workers.drain();
|
|
946
792
|
});
|
|
947
793
|
const startTime = Date.now();
|
|
948
794
|
const healthChecker = new HealthChecker({
|
|
@@ -956,106 +802,131 @@ export class SmartServer {
|
|
|
956
802
|
// with tool vectorization (146+ embedding calls).
|
|
957
803
|
// ---- Config hot-reload (optional) ------------------------------------
|
|
958
804
|
if (this.cfg.configFile) {
|
|
959
|
-
const
|
|
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
|
-
}
|
|
805
|
+
const reloadWatcher = new ConfigReloadWatcher({
|
|
806
|
+
configFile: this.cfg.configFile,
|
|
807
|
+
log,
|
|
808
|
+
applyAgentUpdate: (u) => smartAgent.applyConfigUpdate(u),
|
|
809
|
+
mirrorCfg: (agentPatch, prompts) => {
|
|
997
810
|
if (Object.keys(agentPatch).length > 0) {
|
|
998
|
-
|
|
811
|
+
this.cfg.agent = {
|
|
999
812
|
...(this.cfg.agent ??
|
|
1000
813
|
{}),
|
|
1001
814
|
...agentPatch,
|
|
1002
815
|
};
|
|
1003
|
-
this.cfg.agent =
|
|
1004
|
-
mergedAgent;
|
|
1005
816
|
}
|
|
1006
|
-
if (
|
|
1007
|
-
|
|
1008
|
-
const
|
|
817
|
+
if (prompts.ragTranslate !== undefined ||
|
|
818
|
+
prompts.historySummary !== undefined) {
|
|
819
|
+
const merged = {
|
|
1009
820
|
...(this.cfg.prompts ??
|
|
1010
821
|
{}),
|
|
1011
822
|
};
|
|
1012
|
-
if (
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
mergedPrompts.historySummary = update.prompts.historySummary;
|
|
1017
|
-
}
|
|
823
|
+
if (prompts.ragTranslate !== undefined)
|
|
824
|
+
merged.ragTranslate = prompts.ragTranslate;
|
|
825
|
+
if (prompts.historySummary !== undefined)
|
|
826
|
+
merged.historySummary = prompts.historySummary;
|
|
1018
827
|
this.cfg.prompts =
|
|
1019
|
-
|
|
828
|
+
merged;
|
|
1020
829
|
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
// cookie-known sessionId still returns it. Clear the worker cache so
|
|
1026
|
-
// the next build reads from the just-applied config, then drop every
|
|
1027
|
-
// session graph. Failures are non-fatal — log and continue.
|
|
1028
|
-
// Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing
|
|
1029
|
-
// the cache. Hot-reload runs from a synchronous emitter callback, so
|
|
1030
|
-
// fire-and-forget here — same async-tolerance as the invalidateAll
|
|
1031
|
-
// call below.
|
|
1032
|
-
drainWorkerCache(this._workerLlmCache).catch((err) => {
|
|
1033
|
-
log({ event: 'config_reload_drain_error', error: String(err) });
|
|
1034
|
-
});
|
|
1035
|
-
this._lifecycle?.invalidateAll().catch((err) => {
|
|
1036
|
-
log({ event: 'config_reload_invalidate_error', error: String(err) });
|
|
1037
|
-
});
|
|
1038
|
-
// Apply RAG weight updates
|
|
1039
|
-
if (update.vectorWeight !== undefined ||
|
|
1040
|
-
update.keywordWeight !== undefined) {
|
|
1041
|
-
for (const store of Object.values(ragStores)) {
|
|
1042
|
-
if (store &&
|
|
1043
|
-
typeof store.updateWeights === 'function') {
|
|
1044
|
-
store.updateWeights({
|
|
1045
|
-
vectorWeight: update.vectorWeight,
|
|
1046
|
-
keywordWeight: update.keywordWeight,
|
|
1047
|
-
});
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
});
|
|
1052
|
-
watcher.on('error', (err) => {
|
|
1053
|
-
log({ event: 'config_reload_error', error: String(err) });
|
|
830
|
+
},
|
|
831
|
+
drainWorkers: () => this._workers.drain(),
|
|
832
|
+
invalidateSessions: () => this._lifecycle?.invalidateAll() ?? Promise.resolve(),
|
|
833
|
+
ragStores,
|
|
1054
834
|
});
|
|
1055
|
-
|
|
1056
|
-
closeFns.push(() =>
|
|
835
|
+
reloadWatcher.start();
|
|
836
|
+
closeFns.push(() => reloadWatcher.stop());
|
|
1057
837
|
}
|
|
1058
838
|
const { requestLogger } = agentHandle;
|
|
839
|
+
return {
|
|
840
|
+
close: async () => {
|
|
841
|
+
for (const fn of closeFns)
|
|
842
|
+
await fn();
|
|
843
|
+
},
|
|
844
|
+
chat,
|
|
845
|
+
streamChat,
|
|
846
|
+
requestLogger,
|
|
847
|
+
// Infra/passthrough startup agent — `_start()` serves infra endpoints
|
|
848
|
+
// (HealthChecker / /v1/models) from this.
|
|
849
|
+
smartAgent,
|
|
850
|
+
// Resolved globals the embeddable path needs to assemble the `'embedded'`
|
|
851
|
+
// SessionAgentParts (the HTTP path serves per-session graphs instead).
|
|
852
|
+
globalMcpClients,
|
|
853
|
+
globalRagRegistry,
|
|
854
|
+
log,
|
|
855
|
+
healthChecker,
|
|
856
|
+
modelProvider,
|
|
857
|
+
adapterMap,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Build the embeddable COORDINATED agent for the free `buildAgent(cfg)` path.
|
|
862
|
+
*
|
|
863
|
+
* `_buildInfra().smartAgent` is the INFRA/passthrough startup agent — it has
|
|
864
|
+
* NO coordinator, so it would never run the configured pipeline. The HTTP
|
|
865
|
+
* `start()` path serves the PER-SESSION `graph.agent` (built lazily via
|
|
866
|
+
* buildSessionAgent → buildPipelineInstance) and keeps using `smartAgent` for
|
|
867
|
+
* infra endpoints (/v1/models, health). But the embeddable `buildAgent(cfg)`
|
|
868
|
+
* consumer has no session lifecycle, so it must receive a fully COORDINATED
|
|
869
|
+
* agent. Build ONE pipeline instance via the SAME path a session uses — an
|
|
870
|
+
* `'embedded'` session — over the shared infra, and return ITS agent. This is
|
|
871
|
+
* the ONLY caller that builds the embedded instance, so `start()` no longer
|
|
872
|
+
* pays for an idle coordinator it never serves.
|
|
873
|
+
*
|
|
874
|
+
* @internal — reached only by the same-module free `buildAgent(cfg)`; not part
|
|
875
|
+
* of the documented public API. Public (not `private`) solely so that seam can
|
|
876
|
+
* call it by name (a `private` reached only via an external cast trips
|
|
877
|
+
* `noUnusedLocals`).
|
|
878
|
+
*/
|
|
879
|
+
async _buildEmbeddedAgent() {
|
|
880
|
+
const infra = await this._buildInfra();
|
|
881
|
+
// If the pipeline-instance build throws, the infra (LLM clients, MCP, skill
|
|
882
|
+
// host, pg pools) is already live — tear it down before propagating so a
|
|
883
|
+
// failed embedded build never leaks the infra.
|
|
884
|
+
let inst;
|
|
885
|
+
try {
|
|
886
|
+
inst = await this.buildPipelineInstance({
|
|
887
|
+
sessionId: 'embedded',
|
|
888
|
+
parts: this._embeddedSessionParts(infra.globalMcpClients, infra.globalRagRegistry),
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
catch (e) {
|
|
892
|
+
await infra.close().catch(() => { });
|
|
893
|
+
throw e;
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
// PUBLIC embeddable agent = the coordinated pipeline instance's agent.
|
|
897
|
+
agent: inst.agent,
|
|
898
|
+
// Dispose the pipeline instance FIRST, then the shared infra. `finally`
|
|
899
|
+
// guarantees `infra.close()` runs even if `inst.close()` throws.
|
|
900
|
+
close: async () => {
|
|
901
|
+
try {
|
|
902
|
+
await inst.close();
|
|
903
|
+
}
|
|
904
|
+
finally {
|
|
905
|
+
await infra.close();
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Assemble the `SessionAgentParts` for the single `'embedded'` pipeline
|
|
912
|
+
* instance returned by `_buildEmbeddedAgent` (the embeddable `buildAgent(cfg)`
|
|
913
|
+
* path).
|
|
914
|
+
* Mirrors EXACTLY what the session lifecycle passes to `buildSessionAgent`:
|
|
915
|
+
* the global mcpClients + the global ragRegistry + the global tools store,
|
|
916
|
+
* with a fresh per-(embedded-)session request logger.
|
|
917
|
+
*/
|
|
918
|
+
_embeddedSessionParts(mcpClients, ragRegistry) {
|
|
919
|
+
return {
|
|
920
|
+
sessionId: 'embedded',
|
|
921
|
+
mcpClients: mcpClients ?? this._sharedMcpClients ?? [],
|
|
922
|
+
toolsRag: this._toolsRag,
|
|
923
|
+
ragRegistry,
|
|
924
|
+
logger: new SessionRequestLogger(),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
async _start() {
|
|
928
|
+
const built = await this._buildInfra();
|
|
929
|
+
const { chat, streamChat, requestLogger, smartAgent, log, healthChecker, modelProvider, adapterMap, } = built;
|
|
1059
930
|
const server = http.createServer((req, res) => this._handle(req, res, requestLogger, smartAgent, chat, streamChat, log, healthChecker, modelProvider, adapterMap).catch((err) => {
|
|
1060
931
|
if (!res.headersSent) {
|
|
1061
932
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -1082,8 +953,9 @@ export class SmartServer {
|
|
|
1082
953
|
// 2. Now run lifecycle cleanup: sweep timer, lifecycle.disposeAll,
|
|
1083
954
|
// config watcher stop, agent close. By this point no HTTP
|
|
1084
955
|
// request is in flight, so disposing session graphs is safe.
|
|
1085
|
-
|
|
1086
|
-
|
|
956
|
+
// `built.close()` runs the same `closeFns` loop the original
|
|
957
|
+
// inline close did — order preserved.
|
|
958
|
+
await built.close();
|
|
1087
959
|
},
|
|
1088
960
|
requestLogger,
|
|
1089
961
|
});
|
|
@@ -1140,7 +1012,7 @@ export class SmartServer {
|
|
|
1140
1012
|
const classifierTemp = Number(subFlatLlm?.classifierTemperature ?? 0.1);
|
|
1141
1013
|
const cached = await resolveWorkerLlmSet({
|
|
1142
1014
|
name,
|
|
1143
|
-
cache: this.
|
|
1015
|
+
cache: this._workers.cache,
|
|
1144
1016
|
// Preserve the existing makeLlm derivation exactly.
|
|
1145
1017
|
makeMain: () => makeLlm({
|
|
1146
1018
|
// ?? 'deepseek' is a TS type-narrowing net only; the config
|
|
@@ -1250,7 +1122,7 @@ export class SmartServer {
|
|
|
1250
1122
|
// re-wires never overwrite the cache. See backfillWorkerCacheFromHandle's
|
|
1251
1123
|
// doc-comment for the rationale.
|
|
1252
1124
|
if (!injected) {
|
|
1253
|
-
const entry = this.
|
|
1125
|
+
const entry = this._workers.cache.get(name);
|
|
1254
1126
|
if (entry)
|
|
1255
1127
|
await backfillWorkerCacheFromHandle(entry, handle);
|
|
1256
1128
|
}
|
|
@@ -1258,32 +1130,24 @@ export class SmartServer {
|
|
|
1258
1130
|
}
|
|
1259
1131
|
// -- Pipeline-context dep sources (promoted from the inline coordinator-gate
|
|
1260
1132
|
// closures; consumed by buildServerCtx, which later tasks call) ----------
|
|
1261
|
-
/** Build an LLM from a SmartServerLlmConfig (mirrors stepperMakeLlm/DAG).
|
|
1133
|
+
/** Build an LLM from a SmartServerLlmConfig (mirrors stepperMakeLlm/DAG).
|
|
1134
|
+
* Routes through the BuildAgentDeps seam so an injected `makeLlm` overrides
|
|
1135
|
+
* the real builder. */
|
|
1262
1136
|
_makeLlm(lc) {
|
|
1263
|
-
return makeLlm(
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
}, Number(lc.temperature ?? this._mainTemp ?? 0.7));
|
|
1137
|
+
return this._deps.makeLlm(lc);
|
|
1138
|
+
}
|
|
1139
|
+
/** The real `makeLlm`-backed construction (the seam's default). */
|
|
1140
|
+
_makeLlmDefault(lc) {
|
|
1141
|
+
return makeDefaultRoleLlm(lc, this._mainTemp);
|
|
1269
1142
|
}
|
|
1270
1143
|
/** Resolve a per-role LLM through the normalized map → pipelineFallback chain.
|
|
1271
1144
|
* 'main' returns the captured mainLlm; 'helper'/'classifier' return the
|
|
1272
1145
|
* prebuilt instances when present; otherwise the map/fallback config is built. */
|
|
1273
1146
|
async resolveRoleLlm(role) {
|
|
1274
|
-
if (
|
|
1275
|
-
|
|
1276
|
-
if ((role === 'helper' || role === 'planner') && this._helperLlm) {
|
|
1277
|
-
return this._helperLlm;
|
|
1147
|
+
if (!this._roleLlm) {
|
|
1148
|
+
throw new Error('resolveRoleLlm invoked before _buildInfra built the resolver');
|
|
1278
1149
|
}
|
|
1279
|
-
|
|
1280
|
-
return this._classifierLlm;
|
|
1281
|
-
const cfg = resolveLlmConfig(this._llmMap, role, this._pipelineFallback);
|
|
1282
|
-
if (cfg)
|
|
1283
|
-
return this._makeLlm(cfg);
|
|
1284
|
-
if (this._mainLlm)
|
|
1285
|
-
return this._mainLlm;
|
|
1286
|
-
throw new Error(`cannot resolve LLM for role '${role}': no config`);
|
|
1150
|
+
return this._roleLlm.resolve(role);
|
|
1287
1151
|
}
|
|
1288
1152
|
/**
|
|
1289
1153
|
* Session-scoped knowledge RAG over the shared knowledge backend (built
|
|
@@ -1312,7 +1176,7 @@ export class SmartServer {
|
|
|
1312
1176
|
}
|
|
1313
1177
|
/** callMcp bridge over the shared connected MCP clients (empty when none). */
|
|
1314
1178
|
callMcp(name, args, signal) {
|
|
1315
|
-
return buildMcpBridge(this._sharedMcpClients ?? [])(name, args, signal);
|
|
1179
|
+
return buildMcpBridge(this._sharedMcpClients ?? [], this._mcpFailureClassifier)(name, args, signal);
|
|
1316
1180
|
}
|
|
1317
1181
|
_mintStepperId() {
|
|
1318
1182
|
return randomUUID();
|
|
@@ -1347,7 +1211,7 @@ export class SmartServer {
|
|
|
1347
1211
|
// connect the YAML `mcp:` block ONCE (connect is not safe to invoke twice
|
|
1348
1212
|
// on the same wrapper — guard via the cache field).
|
|
1349
1213
|
if (!mcpClients && !this._stepperMcpClients) {
|
|
1350
|
-
this._stepperMcpClients = await
|
|
1214
|
+
this._stepperMcpClients = await this._deps.connectMcp(this.cfg.mcp);
|
|
1351
1215
|
}
|
|
1352
1216
|
this._sharedMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
|
|
1353
1217
|
await this.buildToolsRagHandle({ toolsRag, resolvedEmbedder });
|
|
@@ -1362,19 +1226,10 @@ export class SmartServer {
|
|
|
1362
1226
|
buildKnowledgeBackend() {
|
|
1363
1227
|
if (this._stepperKnowledgeBackend)
|
|
1364
1228
|
return;
|
|
1365
|
-
|
|
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);
|
|
1229
|
+
this._stepperKnowledgeBackend = makeKnowledgeBackend({
|
|
1230
|
+
logDir: this.cfg.logDir,
|
|
1231
|
+
embedder: this._resolvedEmbedder,
|
|
1232
|
+
});
|
|
1378
1233
|
}
|
|
1379
1234
|
/**
|
|
1380
1235
|
* Build `_toolsRagHandle` — a real IToolsRagHandle over the tools RAG store +
|
|
@@ -1388,71 +1243,7 @@ export class SmartServer {
|
|
|
1388
1243
|
*/
|
|
1389
1244
|
async buildToolsRagHandle(input) {
|
|
1390
1245
|
const { toolsRag, resolvedEmbedder } = input;
|
|
1391
|
-
|
|
1392
|
-
const stepperMcpClients = this._sharedMcpClients ?? [];
|
|
1393
|
-
let catalogCache;
|
|
1394
|
-
const ensureCatalog = async () => {
|
|
1395
|
-
if (catalogCache)
|
|
1396
|
-
return catalogCache;
|
|
1397
|
-
const catalog = new Map();
|
|
1398
|
-
await Promise.allSettled(stepperMcpClients.map(async (client) => {
|
|
1399
|
-
const result = await client.listTools();
|
|
1400
|
-
if (result.ok) {
|
|
1401
|
-
for (const t of result.value) {
|
|
1402
|
-
if (!catalog.has(t.name))
|
|
1403
|
-
catalog.set(t.name, t);
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
}));
|
|
1407
|
-
catalogCache = catalog;
|
|
1408
|
-
return catalog;
|
|
1409
|
-
};
|
|
1410
|
-
this._toolsRagHandle = {
|
|
1411
|
-
async query(text, k, options) {
|
|
1412
|
-
const limit = k ?? 20;
|
|
1413
|
-
const catalog = await ensureCatalog();
|
|
1414
|
-
if (toolsRag && resolvedEmbedder) {
|
|
1415
|
-
// Pass options (requestLogger + trace) so the wrapped embedder logs
|
|
1416
|
-
// this query-embedding against the request.
|
|
1417
|
-
const embedding = new QueryEmbedding(text, resolvedEmbedder, options);
|
|
1418
|
-
const ragResult = await toolsRag.query(embedding, limit);
|
|
1419
|
-
if (ragResult.ok) {
|
|
1420
|
-
const hits = [];
|
|
1421
|
-
for (const r of ragResult.value) {
|
|
1422
|
-
const id = r.metadata.id;
|
|
1423
|
-
if (id?.startsWith('tool:')) {
|
|
1424
|
-
const name = id.slice(5).replace(/:.*$/, '');
|
|
1425
|
-
const tool = catalog.get(name);
|
|
1426
|
-
if (tool)
|
|
1427
|
-
hits.push(tool);
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
if (hits.length > 0)
|
|
1431
|
-
return hits;
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
return [...catalog.values()].slice(0, limit);
|
|
1435
|
-
},
|
|
1436
|
-
lookup(name) {
|
|
1437
|
-
return catalogCache?.get(name);
|
|
1438
|
-
},
|
|
1439
|
-
};
|
|
1440
|
-
// F2: eagerly populate the MCP tool catalog at startup (MCP is connected
|
|
1441
|
-
// above), so the SYNC `lookup(name)` contract (IToolsRagHandle.lookup) returns
|
|
1442
|
-
// a tool schema BEFORE any `query()` runs. `ensureCatalog` is idempotent —
|
|
1443
|
-
// later `query()` calls reuse the cached map. Guard against a catalog-load
|
|
1444
|
-
// failure so startup never crashes: on failure `catalogCache` stays unset and
|
|
1445
|
-
// `lookup` returns undefined (today's worst case), while the happy path works.
|
|
1446
|
-
try {
|
|
1447
|
-
await ensureCatalog();
|
|
1448
|
-
}
|
|
1449
|
-
catch (err) {
|
|
1450
|
-
this.cfg.log?.({
|
|
1451
|
-
event: 'tools_catalog_eager_load_failed',
|
|
1452
|
-
message: 'tools catalog eager-load failed; lookup() returns undefined until first query()',
|
|
1453
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1454
|
-
});
|
|
1455
|
-
}
|
|
1246
|
+
this._toolsRagHandle = await makeToolsRagHandle(this._sharedMcpClients ?? [], toolsRag, resolvedEmbedder, this.cfg.log);
|
|
1456
1247
|
}
|
|
1457
1248
|
/**
|
|
1458
1249
|
* Build the per-session pipeline instance from the registry. Selects the
|
|
@@ -1478,62 +1269,13 @@ export class SmartServer {
|
|
|
1478
1269
|
/**
|
|
1479
1270
|
* Build the FRESH per-session worker (sub-agent) registry from the SAME
|
|
1480
1271
|
* `subagents:` configs the primary build() used, injecting globals + this
|
|
1481
|
-
* session's logger + the CACHED per-worker LLM/embedder (this.
|
|
1272
|
+
* session's logger + the CACHED per-worker LLM/embedder (this._workers.cache).
|
|
1482
1273
|
* NEVER reconstructs LLM clients; NEVER reuses the global registry.
|
|
1483
1274
|
*
|
|
1484
|
-
*
|
|
1485
|
-
* pipeline-plugin context (`buildServerCtx` / `partsToBaseInput`) feed a real
|
|
1486
|
-
* per-session worker map to `buildBaseBuilder` instead of an empty `new Map()`.
|
|
1275
|
+
* Delegates to `this._workers.build(parts)` (WorkerRegistry).
|
|
1487
1276
|
*/
|
|
1488
1277
|
async buildWorkerRegistry(parts) {
|
|
1489
|
-
|
|
1490
|
-
if (!this.cfg.subAgentConfigs || this.cfg.subAgentConfigs.length === 0) {
|
|
1491
|
-
return registry;
|
|
1492
|
-
}
|
|
1493
|
-
if (!this._fileLogger) {
|
|
1494
|
-
throw new Error('buildWorkerRegistry invoked before primary build() captured globals');
|
|
1495
|
-
}
|
|
1496
|
-
for (const sub of this.cfg.subAgentConfigs) {
|
|
1497
|
-
// Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
|
|
1498
|
-
// clears `_workerLlmCache`, the next session build used to throw
|
|
1499
|
-
// "worker LLM set not cached" because the cache was assumed
|
|
1500
|
-
// pre-populated by the primary build(). buildSubAgent itself routes
|
|
1501
|
-
// through `resolveWorkerLlmSet` which is build-on-miss, so calling
|
|
1502
|
-
// it without an `injected` arg rebuilds the cache entry. We then
|
|
1503
|
-
// re-read the entry to honour the per-worker slot priority below.
|
|
1504
|
-
if (!this._workerLlmCache.has(sub.name)) {
|
|
1505
|
-
await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
|
|
1506
|
-
}
|
|
1507
|
-
const cached = this._workerLlmCache.get(sub.name);
|
|
1508
|
-
if (!cached) {
|
|
1509
|
-
// Defence in depth — should be impossible after the lazy build
|
|
1510
|
-
// above unless buildSubAgent's contract changes.
|
|
1511
|
-
throw new Error(`worker LLM set not cached for '${sub.name}'`);
|
|
1512
|
-
}
|
|
1513
|
-
// Per-worker injected slot priority (review HIGH #7):
|
|
1514
|
-
// worker-cached (from the primary build, includes backfilled
|
|
1515
|
-
// subCfg.mcp / subCfg.rag results) → parent's session-scoped
|
|
1516
|
-
// fallback. Encoded HERE so buildSubAgent does not need to know
|
|
1517
|
-
// the difference; it just consumes injected.mcpClients/toolsRag.
|
|
1518
|
-
const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
|
|
1519
|
-
? cached.mcpClients
|
|
1520
|
-
: parts.mcpClients;
|
|
1521
|
-
const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
|
|
1522
|
-
const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
|
|
1523
|
-
ragRegistry: parts.ragRegistry,
|
|
1524
|
-
toolsRag: injectedToolsRag,
|
|
1525
|
-
mcpClients: injectedMcpClients,
|
|
1526
|
-
requestLogger: parts.logger,
|
|
1527
|
-
mainLlm: cached.mainLlm,
|
|
1528
|
-
classifierLlm: cached.classifierLlm,
|
|
1529
|
-
helperLlm: cached.helperLlm,
|
|
1530
|
-
embedder: cached.embedder,
|
|
1531
|
-
});
|
|
1532
|
-
registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
|
|
1533
|
-
description: sub.description,
|
|
1534
|
-
}));
|
|
1535
|
-
}
|
|
1536
|
-
return registry;
|
|
1278
|
+
return this._workers.build(parts);
|
|
1537
1279
|
}
|
|
1538
1280
|
/**
|
|
1539
1281
|
* Map SessionAgentParts → buildBaseBuilder input. `workerRegistry` is the
|
|
@@ -1622,6 +1364,21 @@ export class SmartServer {
|
|
|
1622
1364
|
ragRegistry: scope.parts.ragRegistry,
|
|
1623
1365
|
callMcp: (n, a, s) => this.callMcp(n, a, s),
|
|
1624
1366
|
mcpClients: scope.parts.mcpClients,
|
|
1367
|
+
mcpFailureClassifier: this._mcpFailureClassifier,
|
|
1368
|
+
...(this._toolLoopContextStrategyFactory
|
|
1369
|
+
? {
|
|
1370
|
+
toolLoopContextStrategyFactory: this._toolLoopContextStrategyFactory,
|
|
1371
|
+
}
|
|
1372
|
+
: {}),
|
|
1373
|
+
...(this._stepExecutionControl
|
|
1374
|
+
? { stepExecutionControl: this._stepExecutionControl }
|
|
1375
|
+
: {}),
|
|
1376
|
+
...(this._runExecutionControl
|
|
1377
|
+
? { runExecutionControl: this._runExecutionControl }
|
|
1378
|
+
: {}),
|
|
1379
|
+
...(this._auxiliaryMcpTools
|
|
1380
|
+
? { auxiliaryMcpTools: this._auxiliaryMcpTools }
|
|
1381
|
+
: {}),
|
|
1625
1382
|
subagents: (this.cfg.subAgentConfigs ?? []).map((s) => ({
|
|
1626
1383
|
name: s.name,
|
|
1627
1384
|
description: s.description,
|
|
@@ -1752,6 +1509,16 @@ export class SmartServer {
|
|
|
1752
1509
|
if (parts.workerRegistry.size > 0) {
|
|
1753
1510
|
builder = builder.withSubAgents(parts.workerRegistry);
|
|
1754
1511
|
}
|
|
1512
|
+
// Thread the instance-level MCP failure classifier (DI/programmatic only).
|
|
1513
|
+
builder = builder.withMcpFailureClassifier(this._mcpFailureClassifier);
|
|
1514
|
+
// Tool-loop context strategy for the NON-controller pipelines (default / flat /
|
|
1515
|
+
// linear / dag / direct SmartAgent). Honor a consumer-injected factory; else
|
|
1516
|
+
// default to a bounded RAG-less Window (a strict improvement over Legacy's
|
|
1517
|
+
// unbounded growing transcript). This Window default is applied ONLY on this
|
|
1518
|
+
// builder channel — NOT on the ctx seam — so it never reaches the controller's
|
|
1519
|
+
// own `ctx.toolLoopContextStrategyFactory ?? RagRecall` resolution.
|
|
1520
|
+
builder = builder.withToolLoopContextStrategyFactory(this._toolLoopContextStrategyFactory ??
|
|
1521
|
+
(() => new WindowContextStrategy()));
|
|
1755
1522
|
return builder;
|
|
1756
1523
|
}
|
|
1757
1524
|
/**
|
|
@@ -1760,7 +1527,7 @@ export class SmartServer {
|
|
|
1760
1527
|
* session-scoped pipeline context (`buildServerCtx`) supplies the FRESH
|
|
1761
1528
|
* per-session worker registry + session logger + the global
|
|
1762
1529
|
* ragRegistry/toolsRag/mcpClients + the CACHED per-worker LLM/embedder
|
|
1763
|
-
* (this.
|
|
1530
|
+
* (this._workers.cache) via `createAgentBuilder`. It NEVER reuses the primary
|
|
1764
1531
|
* build()'s global registry/coordinator and NEVER constructs new LLM clients.
|
|
1765
1532
|
*
|
|
1766
1533
|
* The pipeline returns `{ agent, close }`; we register `close` under the
|
|
@@ -1858,758 +1625,173 @@ export class SmartServer {
|
|
|
1858
1625
|
url: rawUrl,
|
|
1859
1626
|
normalizedPath: urlPath,
|
|
1860
1627
|
});
|
|
1861
|
-
|
|
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
|
-
}
|
|
1628
|
+
// Server readiness: derived from the agent's MCP connection strategy (it
|
|
1629
|
+
// implements IReadinessReporter). No strategy / non-reporting ⇒ ready
|
|
1630
|
+
// (readiness unknown). Computed ONCE here and reused by /health and the
|
|
1631
|
+
// pre-dispatch request gate (messages/chat) via `rc.ready`.
|
|
1632
|
+
const ready = isReadinessReporter(smartAgent) ? smartAgent.isReady() : true;
|
|
1633
|
+
const rc = {
|
|
1634
|
+
req,
|
|
1635
|
+
res,
|
|
1636
|
+
rawUrl,
|
|
1637
|
+
urlPath,
|
|
1638
|
+
method: req.method ?? 'GET',
|
|
1639
|
+
ready,
|
|
1640
|
+
server: this,
|
|
1641
|
+
requestLogger,
|
|
1642
|
+
smartAgent,
|
|
1643
|
+
chat,
|
|
1644
|
+
streamChat,
|
|
1645
|
+
log,
|
|
1646
|
+
healthChecker,
|
|
1647
|
+
modelProvider,
|
|
1648
|
+
adapterMap,
|
|
1649
|
+
};
|
|
1650
|
+
await this._routeTable.dispatch(rc);
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Declarative route table replacing `_handle`'s if/else chain. Routes are
|
|
1654
|
+
* registered in the EXACT order the original chain checked them (first
|
|
1655
|
+
* method+path match wins), so dispatch is behaviour-identical. Each handler
|
|
1656
|
+
* body is the corresponding original branch moved verbatim, with `this`
|
|
1657
|
+
* accessed through `rc.server` and the request locals read from `rc`.
|
|
1658
|
+
*/
|
|
1659
|
+
_buildRouteTable() {
|
|
1660
|
+
const table = new HttpRouteTable();
|
|
1661
|
+
table.add({
|
|
1662
|
+
method: 'GET',
|
|
1663
|
+
match: (p) => p === '/v1/models' || p === '/models',
|
|
1664
|
+
handle: (rc) => handleModelsList(rc),
|
|
1665
|
+
});
|
|
1666
|
+
table.add({
|
|
1667
|
+
method: 'GET',
|
|
1668
|
+
match: (p) => p === '/v1/embedding-models' || p === '/embedding-models',
|
|
1669
|
+
handle: (rc) => handleEmbeddingModelsList(rc),
|
|
1670
|
+
});
|
|
1671
|
+
table.add({
|
|
1672
|
+
method: 'GET',
|
|
1673
|
+
match: (p) => p === '/v1/usage',
|
|
1674
|
+
handle: (rc) => handleUsageRoute(rc, rc.server._lifecycle),
|
|
1675
|
+
});
|
|
1940
1676
|
// GET /v1/sessions — list sessions for the current identity
|
|
1941
|
-
|
|
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
|
-
}
|
|
1677
|
+
table.add({
|
|
1678
|
+
method: 'GET',
|
|
1679
|
+
match: (p) => p === '/v1/sessions',
|
|
1680
|
+
handle: (rc) => handleSessionsList(rc, rc.server._lifecycle, rc.server._sessionMetaStore),
|
|
1681
|
+
});
|
|
1960
1682
|
// POST /v1/sessions/:id/resume — resume a session
|
|
1961
|
-
{
|
|
1962
|
-
|
|
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
|
-
}
|
|
1683
|
+
table.add({
|
|
1684
|
+
method: 'POST',
|
|
1685
|
+
match: (p) => p.match(/^\/v1\/sessions\/([^/]+)\/resume$/) ?? false,
|
|
1686
|
+
handle: (rc) => handleSessionResume(rc, rc.server._lifecycle, rc.server._sessionMetaStore),
|
|
1687
|
+
});
|
|
1985
1688
|
// DELETE /v1/sessions/:id — delete a session
|
|
1986
|
-
{
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1689
|
+
table.add({
|
|
1690
|
+
method: 'DELETE',
|
|
1691
|
+
match: (p) => p.match(/^\/v1\/sessions\/([^/]+)$/) ?? false,
|
|
1692
|
+
handle: (rc) => handleSessionDelete(rc, rc.server._lifecycle, rc.server._sessionMetaStore, rc.server._stepperKnowledgeBackend),
|
|
1693
|
+
});
|
|
1694
|
+
// /v1/config or /config — any method (dispatches GET/PUT/405 internally)
|
|
1695
|
+
table.add({
|
|
1696
|
+
method: '*',
|
|
1697
|
+
match: (p) => p === '/v1/config' || p === '/config',
|
|
1698
|
+
handle: async (rc) => {
|
|
1699
|
+
if (rc.method === 'GET') {
|
|
1700
|
+
const models = rc.smartAgent.getActiveConfig();
|
|
1701
|
+
const agent = rc.smartAgent.getAgentConfig();
|
|
1702
|
+
const body = { models, agent };
|
|
1703
|
+
rc.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1704
|
+
rc.res.end(JSON.stringify(body));
|
|
1994
1705
|
return;
|
|
1995
1706
|
}
|
|
1996
|
-
|
|
1997
|
-
req.
|
|
1998
|
-
|
|
1999
|
-
if (resolved.minted && resolved.setCookie) {
|
|
2000
|
-
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1707
|
+
if (rc.method === 'PUT') {
|
|
1708
|
+
await handleConfigUpdate(rc.req, rc.res, rc.smartAgent, this._configUpdateTarget());
|
|
1709
|
+
return;
|
|
2001
1710
|
}
|
|
2002
|
-
|
|
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
|
-
}
|
|
1711
|
+
// 405 for other methods
|
|
1712
|
+
rc.res.setHeader('Allow', 'GET, PUT, OPTIONS');
|
|
1713
|
+
rc.res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
1714
|
+
rc.res.end(jsonError(`Method ${rc.req.method} not allowed on ${rc.urlPath}`, 'invalid_request_error'));
|
|
1715
|
+
},
|
|
1716
|
+
});
|
|
1717
|
+
table.add({
|
|
1718
|
+
method: 'GET',
|
|
1719
|
+
match: (p) => p === '/health' || p === '/v1/health',
|
|
1720
|
+
handle: (rc) => handleHealthRoute(rc),
|
|
1721
|
+
});
|
|
2048
1722
|
// POST /v1/messages or /messages → Anthropic adapter
|
|
2049
|
-
|
|
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`);
|
|
1723
|
+
table.add({
|
|
1724
|
+
method: 'POST',
|
|
1725
|
+
match: (p) => p === '/v1/messages' || p === '/messages',
|
|
1726
|
+
handle: async (rc) => {
|
|
1727
|
+
// Pre-dispatch readiness gate: fail loud (503) BEFORE opening any stream.
|
|
1728
|
+
if (!rc.ready) {
|
|
1729
|
+
writeNotReady(rc.res);
|
|
1730
|
+
return;
|
|
2380
1731
|
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
1732
|
+
const anthropicAdapter = rc.adapterMap?.get('anthropic');
|
|
1733
|
+
if (!anthropicAdapter) {
|
|
1734
|
+
rc.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1735
|
+
rc.res.end(jsonError('Anthropic adapter not registered', 'not_found'));
|
|
1736
|
+
return;
|
|
2384
1737
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
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,
|
|
1738
|
+
await rc.server._withSession(rc.req, rc.res, async (graph, sessionId, traceId) => {
|
|
1739
|
+
await handleAdapterRequest(rc.req, rc.res, graph.agent ?? rc.smartAgent, anthropicAdapter, { sessionId, traceId, graph });
|
|
1740
|
+
});
|
|
1741
|
+
},
|
|
2442
1742
|
});
|
|
2443
|
-
|
|
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,
|
|
1743
|
+
table.add({
|
|
1744
|
+
method: 'POST',
|
|
1745
|
+
match: (p) => p === '/v1/chat/completions' || p === '/chat/completions',
|
|
1746
|
+
handle: async (rc) => {
|
|
1747
|
+
// Pre-dispatch readiness gate: fail loud (503) BEFORE opening any SSE stream.
|
|
1748
|
+
if (!rc.ready) {
|
|
1749
|
+
writeNotReady(rc.res);
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
await rc.server._withSession(rc.req, rc.res, async (graph, sessionId, traceId) => {
|
|
1753
|
+
await handleChat(rc.req, rc.res, rc.requestLogger, graph.agent ?? rc.smartAgent, rc.chat, rc.streamChat, rc.log, rc.modelProvider, { sessionId, traceId, graph }, this.cfg);
|
|
1754
|
+
});
|
|
2459
1755
|
},
|
|
2460
|
-
})
|
|
1756
|
+
});
|
|
1757
|
+
return table;
|
|
2461
1758
|
}
|
|
2462
|
-
/**
|
|
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 }));
|
|
1759
|
+
/**
|
|
1760
|
+
* Build the PUT /v1/config hot-swap seam over this server's private state.
|
|
1761
|
+
* The setters write the SAME `_mainLlm`/`_classifierLlm`/`_helperLlm` fields
|
|
1762
|
+
* RoleLlmResolver's live accessors read, so the hot-swap stays observable.
|
|
1763
|
+
* A private object literal — NOT `implements` — so the public class shape is
|
|
1764
|
+
* unchanged (byte-stable public API).
|
|
1765
|
+
*/
|
|
1766
|
+
_configUpdateTarget() {
|
|
1767
|
+
return {
|
|
1768
|
+
modelResolver: this.cfg.modelResolver,
|
|
1769
|
+
setMainLlm: (llm) => {
|
|
1770
|
+
this._mainLlm = llm;
|
|
1771
|
+
},
|
|
1772
|
+
setClassifierLlm: (llm) => {
|
|
1773
|
+
this._classifierLlm = llm;
|
|
1774
|
+
},
|
|
1775
|
+
setHelperLlm: (llm) => {
|
|
1776
|
+
this._helperLlm = llm;
|
|
1777
|
+
},
|
|
1778
|
+
mirrorAgentCfg: (patch) => {
|
|
1779
|
+
const merged = {
|
|
1780
|
+
...(this.cfg.agent ?? {}),
|
|
1781
|
+
...patch,
|
|
1782
|
+
};
|
|
1783
|
+
this.cfg.agent = merged;
|
|
1784
|
+
},
|
|
1785
|
+
drainWorkers: () => this._workers.drain(),
|
|
1786
|
+
invalidateSessions: () => this._lifecycle?.invalidateAll() ?? Promise.resolve(),
|
|
1787
|
+
};
|
|
2613
1788
|
}
|
|
2614
1789
|
}
|
|
1790
|
+
/** Build a runnable agent for any configured pipeline WITHOUT binding a port.
|
|
1791
|
+
* `SmartServer.start()` is the default impl that adds HTTP `listen` on top. */
|
|
1792
|
+
export async function buildAgent(cfg, deps) {
|
|
1793
|
+
const server = new SmartServer(cfg, deps);
|
|
1794
|
+
const built = await server._buildEmbeddedAgent();
|
|
1795
|
+
return { agent: built.agent, close: built.close };
|
|
1796
|
+
}
|
|
2615
1797
|
//# sourceMappingURL=smart-server.js.map
|