@mcp-abap-adt/llm-agent-server-libs 18.1.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/README.md +42 -0
- package/dist/factories/cyclic-factory.d.ts +48 -0
- package/dist/factories/cyclic-factory.d.ts.map +1 -0
- package/dist/factories/cyclic-factory.js +35 -0
- package/dist/factories/cyclic-factory.js.map +1 -0
- package/dist/factories/dag-factory.d.ts +13 -0
- package/dist/factories/dag-factory.d.ts.map +1 -0
- package/dist/factories/dag-factory.js +14 -0
- package/dist/factories/dag-factory.js.map +1 -0
- package/dist/factories/deep-stepper-factory.d.ts +12 -0
- package/dist/factories/deep-stepper-factory.d.ts.map +1 -0
- package/dist/factories/deep-stepper-factory.js +13 -0
- package/dist/factories/deep-stepper-factory.js.map +1 -0
- package/dist/factories/index.d.ts +7 -0
- package/dist/factories/index.d.ts.map +1 -0
- package/dist/factories/index.js +6 -0
- package/dist/factories/index.js.map +1 -0
- package/dist/factories/linear-factory.d.ts +13 -0
- package/dist/factories/linear-factory.d.ts.map +1 -0
- package/dist/factories/linear-factory.js +14 -0
- package/dist/factories/linear-factory.js.map +1 -0
- package/dist/factories/planned-factory.d.ts +12 -0
- package/dist/factories/planned-factory.d.ts.map +1 -0
- package/dist/factories/planned-factory.js +13 -0
- package/dist/factories/planned-factory.js.map +1 -0
- package/dist/generated/version.d.ts +2 -0
- package/dist/generated/version.d.ts.map +1 -0
- package/dist/generated/version.js +3 -0
- package/dist/generated/version.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/smart-agent/build-dag-coordinator-deps.d.ts +34 -0
- package/dist/smart-agent/build-dag-coordinator-deps.d.ts.map +1 -0
- package/dist/smart-agent/build-dag-coordinator-deps.js +107 -0
- package/dist/smart-agent/build-dag-coordinator-deps.js.map +1 -0
- package/dist/smart-agent/build-stepper-root.d.ts +110 -0
- package/dist/smart-agent/build-stepper-root.d.ts.map +1 -0
- package/dist/smart-agent/build-stepper-root.js +270 -0
- package/dist/smart-agent/build-stepper-root.js.map +1 -0
- package/dist/smart-agent/config.d.ts +282 -0
- package/dist/smart-agent/config.d.ts.map +1 -0
- package/dist/smart-agent/config.js +1109 -0
- package/dist/smart-agent/config.js.map +1 -0
- package/dist/smart-agent/jsonl-knowledge-backend.d.ts +19 -0
- package/dist/smart-agent/jsonl-knowledge-backend.d.ts.map +1 -0
- package/dist/smart-agent/jsonl-knowledge-backend.js +46 -0
- package/dist/smart-agent/jsonl-knowledge-backend.js.map +1 -0
- package/dist/smart-agent/pipeline.d.ts +89 -0
- package/dist/smart-agent/pipeline.d.ts.map +1 -0
- package/dist/smart-agent/pipeline.js +8 -0
- package/dist/smart-agent/pipeline.js.map +1 -0
- package/dist/smart-agent/resolve-agent-embedder.d.ts +34 -0
- package/dist/smart-agent/resolve-agent-embedder.d.ts.map +1 -0
- package/dist/smart-agent/resolve-agent-embedder.js +48 -0
- package/dist/smart-agent/resolve-agent-embedder.js.map +1 -0
- package/dist/smart-agent/session-identity-resolver.d.ts +17 -0
- package/dist/smart-agent/session-identity-resolver.d.ts.map +1 -0
- package/dist/smart-agent/session-identity-resolver.js +37 -0
- package/dist/smart-agent/session-identity-resolver.js.map +1 -0
- package/dist/smart-agent/session-meta-store.d.ts +53 -0
- package/dist/smart-agent/session-meta-store.d.ts.map +1 -0
- package/dist/smart-agent/session-meta-store.js +36 -0
- package/dist/smart-agent/session-meta-store.js.map +1 -0
- package/dist/smart-agent/smart-server.d.ts +525 -0
- package/dist/smart-agent/smart-server.d.ts.map +1 -0
- package/dist/smart-agent/smart-server.js +2309 -0
- package/dist/smart-agent/smart-server.js.map +1 -0
- package/dist/smart-agent/stepper-coordinator-handler.d.ts +36 -0
- package/dist/smart-agent/stepper-coordinator-handler.d.ts.map +1 -0
- package/dist/smart-agent/stepper-coordinator-handler.js +230 -0
- package/dist/smart-agent/stepper-coordinator-handler.js.map +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,2309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SmartServer — embeddable OpenAI-compatible HTTP server backed by SmartAgent.
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import { AdapterValidationError, artifactIdentityKey, normalizeAndValidateExternalTools, QueryEmbedding, toToolCallDelta, } from '@mcp-abap-adt/llm-agent';
|
|
7
|
+
import { ClaudeSkillManager, CodexSkillManager, ConfigWatcher, DefaultSubAgentContextBuilder, FileSystemPluginLoader, FileSystemSkillManager, getDefaultPluginDirs, HealthChecker, InMemoryKnowledgeBackend, KnowledgeRag, makeLlm, SessionGraphFactory, SessionLogger, SessionRegistry, SmartAgentBuilder, SmartAgentSubAgent, SubAgentStateOracle, } from '@mcp-abap-adt/llm-agent-libs';
|
|
8
|
+
import { MCPClientWrapper, McpClientAdapter, } from '@mcp-abap-adt/llm-agent-mcp';
|
|
9
|
+
import { makeRag } from '@mcp-abap-adt/llm-agent-rag';
|
|
10
|
+
import { PACKAGE_VERSION } from '../generated/version.js';
|
|
11
|
+
import { resolveAgentEmbedder, resolveToolsStoreEmbedder, } from './resolve-agent-embedder.js';
|
|
12
|
+
import { resolveSessionIdentity } from './session-identity-resolver.js';
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function mapStopReason(r) {
|
|
17
|
+
if (r === 'stop')
|
|
18
|
+
return 'stop';
|
|
19
|
+
if (r === 'tool_calls')
|
|
20
|
+
return 'tool_calls';
|
|
21
|
+
return 'length';
|
|
22
|
+
}
|
|
23
|
+
function jsonError(message, type, code) {
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
error: { message, type, ...(code ? { code } : {}) },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function jsonValidationError(message, code, param) {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
error: {
|
|
31
|
+
message,
|
|
32
|
+
type: 'invalid_request_error',
|
|
33
|
+
code,
|
|
34
|
+
param,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function readBody(req) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
req.on('data', (c) => chunks.push(c));
|
|
42
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
43
|
+
req.on('error', reject);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function resolveSkillManager(cfg) {
|
|
47
|
+
if (!cfg)
|
|
48
|
+
return undefined;
|
|
49
|
+
const type = cfg.type ?? 'claude';
|
|
50
|
+
const root = cfg.projectRoot ?? process.cwd();
|
|
51
|
+
switch (type) {
|
|
52
|
+
case 'claude':
|
|
53
|
+
return new ClaudeSkillManager(root);
|
|
54
|
+
case 'codex':
|
|
55
|
+
return new CodexSkillManager(root);
|
|
56
|
+
case 'filesystem':
|
|
57
|
+
return new FileSystemSkillManager(cfg.dirs ?? []);
|
|
58
|
+
default:
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const CORS_HEADERS = {
|
|
63
|
+
'Access-Control-Allow-Origin': '*',
|
|
64
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
|
|
65
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
66
|
+
};
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// SmartServer
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
import { buildDagCoordinatorDeps } from './build-dag-coordinator-deps.js';
|
|
71
|
+
import { buildStepperRoot } from './build-stepper-root.js';
|
|
72
|
+
import { assertCoordinatorConfigShape, normalizeLlmConfig, parseStepperCoordinatorConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorDispatchKind, resolveCoordinatorPlanning, resolveLlmConfig, resolveLlmConfigStrict, resolveToolSelectionStrategy, } from './config.js';
|
|
73
|
+
import { JsonlKnowledgeBackend } from './jsonl-knowledge-backend.js';
|
|
74
|
+
import { InMemorySessionMetaStore } from './session-meta-store.js';
|
|
75
|
+
import { StepperCoordinatorHandler } from './stepper-coordinator-handler.js';
|
|
76
|
+
export { generateConfigTemplate, loadYamlConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorPlanning, resolveEnvVars, resolveSmartServerConfig, resolveToolSelectionStrategy, YAML_TEMPLATE, } from './config.js';
|
|
77
|
+
/**
|
|
78
|
+
* Drain every cached worker's `close` (if any), then clear the cache map.
|
|
79
|
+
* Used by config-reload (PUT /v1/config + hot-reload — Fix #14/18/21) and by
|
|
80
|
+
* server `close()` to release per-worker MCP connections that were attached
|
|
81
|
+
* to the discarded `SmartAgentHandle`s.
|
|
82
|
+
*
|
|
83
|
+
* IMPORTANT — in-flight caveat: this aborts any request that is mid-call on
|
|
84
|
+
* a worker's MCP client. That is acceptable for an admin action (config
|
|
85
|
+
* reload, server shutdown) where the alternative is leaking connections.
|
|
86
|
+
* Server `close()` calls this AFTER `lifecycle.disposeAll()` so per-session
|
|
87
|
+
* graphs that reference worker clients are torn down first.
|
|
88
|
+
*
|
|
89
|
+
* Uses `Promise.allSettled` so one failing close cannot block the others.
|
|
90
|
+
*/
|
|
91
|
+
export async function drainWorkerCache(cache) {
|
|
92
|
+
const closers = [];
|
|
93
|
+
for (const entry of cache.values()) {
|
|
94
|
+
if (entry.close) {
|
|
95
|
+
try {
|
|
96
|
+
closers.push(entry.close());
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// sync throw (defensive — close is async by contract)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cache.clear();
|
|
104
|
+
if (closers.length > 0) {
|
|
105
|
+
await Promise.allSettled(closers);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build-once-per-worker resolver. The first time a worker name is seen, it
|
|
110
|
+
* constructs the worker's main/classifier/(optional helper) LLM + embedder and
|
|
111
|
+
* caches the set; every later call (e.g. each per-session worker re-wire)
|
|
112
|
+
* returns the SAME set by reference — never reconstructing LLM clients
|
|
113
|
+
* (locked invariant: LLM/embedder clients are global, built once).
|
|
114
|
+
*
|
|
115
|
+
* Accepts optional `makeToolsRag`/`makeHistoryRag`/`makeMcpClients` factories;
|
|
116
|
+
* when provided, the resolver builds them ONCE on the first miss and caches
|
|
117
|
+
* them on the returned set. Subsequent calls return the cached resources by
|
|
118
|
+
* reference — never re-vectorizing or re-connecting MCP.
|
|
119
|
+
*/
|
|
120
|
+
export async function resolveWorkerLlmSet(input) {
|
|
121
|
+
const hit = input.cache.get(input.name);
|
|
122
|
+
if (hit)
|
|
123
|
+
return hit;
|
|
124
|
+
const mainLlm = await input.makeMain();
|
|
125
|
+
const classifierLlm = await input.makeClassifier();
|
|
126
|
+
const helperLlm = input.makeHelper ? await input.makeHelper() : undefined;
|
|
127
|
+
const embedder = input.makeEmbedder ? await input.makeEmbedder() : undefined;
|
|
128
|
+
const toolsRag = input.makeToolsRag ? await input.makeToolsRag() : undefined;
|
|
129
|
+
const historyRag = input.makeHistoryRag
|
|
130
|
+
? await input.makeHistoryRag()
|
|
131
|
+
: undefined;
|
|
132
|
+
const mcpClients = input.makeMcpClients
|
|
133
|
+
? await input.makeMcpClients()
|
|
134
|
+
: undefined;
|
|
135
|
+
const set = {
|
|
136
|
+
mainLlm,
|
|
137
|
+
classifierLlm,
|
|
138
|
+
helperLlm,
|
|
139
|
+
embedder,
|
|
140
|
+
toolsRag,
|
|
141
|
+
historyRag,
|
|
142
|
+
mcpClients,
|
|
143
|
+
};
|
|
144
|
+
input.cache.set(input.name, set);
|
|
145
|
+
return set;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Backfill the per-worker cache entry from the BUILT handle (review HIGH #7).
|
|
149
|
+
*
|
|
150
|
+
* The primary `buildSubAgent` populates `cached.mcpClients`/`toolsRag`/
|
|
151
|
+
* `historyRag` only when the worker config provided DI factories. Workers
|
|
152
|
+
* configured with `subCfg.mcp: ...` (regular config that triggers the
|
|
153
|
+
* builder's own auto-connect) or with `subCfg.rag: ...` whose RAG is owned
|
|
154
|
+
* by the builder leave those slots empty — so per-session re-wires would
|
|
155
|
+
* fall back to the PARENT's MCP/RAG, losing the worker's own connection.
|
|
156
|
+
*
|
|
157
|
+
* After the builder finishes, this helper captures what the handle actually
|
|
158
|
+
* holds and stores it BY REFERENCE on the cache entry. Subsequent per-session
|
|
159
|
+
* re-wires read the same slots and find the worker's own resources.
|
|
160
|
+
*
|
|
161
|
+
* Pure helper, mutates `entry` in place. No-op when the corresponding slot
|
|
162
|
+
* is already populated (DI path wins) or when the handle has no resource for
|
|
163
|
+
* that slot (worker simply didn't declare one).
|
|
164
|
+
*/
|
|
165
|
+
export async function backfillWorkerCacheFromHandle(entry, handle) {
|
|
166
|
+
if ((!entry.mcpClients || entry.mcpClients.length === 0) &&
|
|
167
|
+
handle.mcpClients &&
|
|
168
|
+
handle.mcpClients.length > 0) {
|
|
169
|
+
entry.mcpClients = handle.mcpClients;
|
|
170
|
+
}
|
|
171
|
+
if (!entry.toolsRag) {
|
|
172
|
+
const t = handle.ragRegistry.get('tools');
|
|
173
|
+
if (t)
|
|
174
|
+
entry.toolsRag = t;
|
|
175
|
+
}
|
|
176
|
+
if (!entry.historyRag) {
|
|
177
|
+
const h = handle.ragRegistry.get('history');
|
|
178
|
+
if (h)
|
|
179
|
+
entry.historyRag = h;
|
|
180
|
+
}
|
|
181
|
+
// Capture the per-worker shutdown function (Fix #21). If the entry already
|
|
182
|
+
// had a close from a previous build (e.g. the same worker name was rebuilt
|
|
183
|
+
// WITHOUT going through `drainWorkerCache` first — defence in depth), await
|
|
184
|
+
// the prior close before overwriting so its MCP connections do not leak.
|
|
185
|
+
if (handle.close) {
|
|
186
|
+
if (entry.close) {
|
|
187
|
+
try {
|
|
188
|
+
await entry.close();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Best-effort; never block the new build on a stale close failure.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
entry.close = handle.close;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Share the parent RAG registry with subagents (per-session worker re-wire).
|
|
199
|
+
* Session/user/global collections written at the top level become visible to
|
|
200
|
+
* workers; the per-call scope filter (`rag-query.ts`) isolates by
|
|
201
|
+
* `ctx.sessionId` / `ctx.options.userId`. A worker's own declared store is
|
|
202
|
+
* registered INTO this same registry under its namespace. When the parent
|
|
203
|
+
* registry is undefined (no top-level registry yet — e.g. unit test seam),
|
|
204
|
+
* return undefined so the builder allocates its own SimpleRagRegistry.
|
|
205
|
+
*/
|
|
206
|
+
export function resolveSubAgentRagRegistry(input) {
|
|
207
|
+
return input.parentRagRegistry;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Composes the cookie identity resolver + SessionGraphFactory + SessionRegistry
|
|
211
|
+
* into one lifecycle object the server's `_handle` consumes. The default MCP
|
|
212
|
+
* factory returns the shared GLOBAL clients by reference (one upstream
|
|
213
|
+
* connection); a creds-aware build swaps it out (out of scope here).
|
|
214
|
+
*/
|
|
215
|
+
export function buildSessionLifecycle(opts) {
|
|
216
|
+
const factory = new SessionGraphFactory({
|
|
217
|
+
mcpClientFactory: (_identity) => opts.mcpClients,
|
|
218
|
+
toolsRag: opts.toolsRag,
|
|
219
|
+
ragRegistry: opts.ragRegistry,
|
|
220
|
+
buildAgent: opts.buildAgent,
|
|
221
|
+
logger: opts.logger,
|
|
222
|
+
});
|
|
223
|
+
const registry = new SessionRegistry({
|
|
224
|
+
idleTtlMs: opts.idleTtlMs,
|
|
225
|
+
maxSessions: opts.maxSessions,
|
|
226
|
+
factory,
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
resolve: (cookieHeader, isHttps) => resolveSessionIdentity({
|
|
230
|
+
cookieHeader,
|
|
231
|
+
cookieName: opts.cookieName,
|
|
232
|
+
maxAgeSeconds: Math.max(1, Math.floor(opts.idleTtlMs / 1000)),
|
|
233
|
+
isHttps,
|
|
234
|
+
}),
|
|
235
|
+
acquire: (sessionId) => registry.acquire(sessionId),
|
|
236
|
+
release: (sessionId, graph) => registry.release(sessionId, graph),
|
|
237
|
+
evictIdle: () => registry.evictIdle(),
|
|
238
|
+
disposeAll: () => registry.disposeAll(),
|
|
239
|
+
invalidateAll: () => registry.invalidateAll(),
|
|
240
|
+
registry,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Seed session-scope guidance entries into a BRAND-NEW session's knowledge-RAG
|
|
245
|
+
* (deployment-supplied tool-usage guidance the planner/executor read in "Known
|
|
246
|
+
* facts"). Idempotent: rehydrates via init() and writes ONLY when the session is
|
|
247
|
+
* empty (`fingerprint() === 'n=0'`), so resumes never duplicate. Entries are
|
|
248
|
+
* config DATA — the runtime stays MCP-agnostic (no tool knowledge in agent code).
|
|
249
|
+
*/
|
|
250
|
+
export async function seedSessionKnowledge(kr, seeds, nowIso) {
|
|
251
|
+
if (seeds.length === 0)
|
|
252
|
+
return;
|
|
253
|
+
await kr.init?.();
|
|
254
|
+
if (kr.fingerprint?.() !== 'n=0')
|
|
255
|
+
return; // not a brand-new session → skip
|
|
256
|
+
for (const s of seeds) {
|
|
257
|
+
await kr.write({
|
|
258
|
+
content: s.content,
|
|
259
|
+
metadata: {
|
|
260
|
+
traceId: 'seed',
|
|
261
|
+
turnId: 'seed',
|
|
262
|
+
stepperId: 'seed',
|
|
263
|
+
task: 'session-seed',
|
|
264
|
+
artifactType: s.artifactType,
|
|
265
|
+
createdAt: nowIso,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Record that a request for `sessionId` STARTED — create the meta row on first
|
|
272
|
+
* sight, else touch it and mark in-progress. Called from the live request path
|
|
273
|
+
* (`_withSession`) so GET /v1/sessions, resume and delete actually see sessions
|
|
274
|
+
* produced by normal chat/stream traffic (review Finding 3). `userIdentity` is
|
|
275
|
+
* the sessionId itself in the default no-auth build — matching how the
|
|
276
|
+
* /v1/sessions endpoints resolve identity (`resolved.identity.sessionId`).
|
|
277
|
+
*/
|
|
278
|
+
export async function recordSessionStart(store, sessionId, nowIso) {
|
|
279
|
+
const existing = await store.get(sessionId);
|
|
280
|
+
if (!existing) {
|
|
281
|
+
await store.create({
|
|
282
|
+
sessionId,
|
|
283
|
+
userIdentity: sessionId,
|
|
284
|
+
createdAt: nowIso,
|
|
285
|
+
lastUsedAt: nowIso,
|
|
286
|
+
status: 'in-progress',
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
await store.touch(sessionId, nowIso);
|
|
291
|
+
await store.setStatus(sessionId, 'in-progress');
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Record that a request for `sessionId` FINISHED — touch + mark idle (so it can
|
|
295
|
+
* be resumed). No-op if the row was deleted mid-flight.
|
|
296
|
+
*/
|
|
297
|
+
export async function recordSessionEnd(store, sessionId, nowIso) {
|
|
298
|
+
const existing = await store.get(sessionId);
|
|
299
|
+
if (!existing)
|
|
300
|
+
return;
|
|
301
|
+
await store.touch(sessionId, nowIso);
|
|
302
|
+
await store.setStatus(sessionId, 'idle');
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* List all sessions for a given user identity.
|
|
306
|
+
* Extracted for unit-testability (mirrors the /v1/usage handler pattern).
|
|
307
|
+
*/
|
|
308
|
+
export async function handleListSessions(store, identity) {
|
|
309
|
+
const sessions = await store.listForUser(identity);
|
|
310
|
+
return { sessions };
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Resume (claim) a session by ID for a user identity.
|
|
314
|
+
* Sets the session status to 'idle' so it can be re-entered.
|
|
315
|
+
*/
|
|
316
|
+
export async function handleResumeSession(store, identity, id) {
|
|
317
|
+
const row = await store.get(id);
|
|
318
|
+
if (!row || row.userIdentity !== identity) {
|
|
319
|
+
return { ok: false, error: 'session not found' };
|
|
320
|
+
}
|
|
321
|
+
await store.setStatus(id, 'idle');
|
|
322
|
+
const updated = await store.get(id);
|
|
323
|
+
return { ok: true, session: updated };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Delete a session by ID for a user identity, and evict its RAG state.
|
|
327
|
+
*/
|
|
328
|
+
export async function handleDeleteSession(store, identity, id, evictFn) {
|
|
329
|
+
const row = await store.get(id);
|
|
330
|
+
if (!row || row.userIdentity !== identity) {
|
|
331
|
+
return { ok: false, error: 'session not found' };
|
|
332
|
+
}
|
|
333
|
+
await store.delete(id);
|
|
334
|
+
await evictFn(id);
|
|
335
|
+
return { ok: true };
|
|
336
|
+
}
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// MCP bridge for the Stepper path (B-1)
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
/**
|
|
341
|
+
* Build a `callMcp(name, args, signal?)` bridge over a list of `IMcpClient`s.
|
|
342
|
+
*
|
|
343
|
+
* Dispatch strategy (mirrors the 17.0 tool-loop):
|
|
344
|
+
* - Iterate the clients; the first client whose `listTools()` contains `name` wins.
|
|
345
|
+
* - On success: return the textual content (stringify structured payloads).
|
|
346
|
+
* - On error: return the error message as a string so the LLM executor can
|
|
347
|
+
* feed the failure back to the model as a tool result (no throw).
|
|
348
|
+
* - If no client owns the tool: return an informative "Tool not found" string.
|
|
349
|
+
*
|
|
350
|
+
* Exported for testability — tests can call this with a fake IMcpClient list.
|
|
351
|
+
*/
|
|
352
|
+
/**
|
|
353
|
+
* Connect MCP clients from a YAML `mcp:` config block (single or array).
|
|
354
|
+
*
|
|
355
|
+
* Mirrors the builder's connection logic (builder.ts ~lines 897-920) so the
|
|
356
|
+
* Stepper path gets the same clients that the builder would have connected
|
|
357
|
+
* internally. Exported for testability.
|
|
358
|
+
*
|
|
359
|
+
* @param mcpCfg - single `SmartServerMcpConfig` or array thereof (from
|
|
360
|
+
* `pipeline.mcp` or `this.cfg.mcp`). Accepts the union so callers can pass
|
|
361
|
+
* either directly without pre-normalising.
|
|
362
|
+
*/
|
|
363
|
+
export async function connectMcpClientsFromConfig(mcpCfg) {
|
|
364
|
+
if (!mcpCfg)
|
|
365
|
+
return [];
|
|
366
|
+
const list = Array.isArray(mcpCfg) ? mcpCfg : [mcpCfg];
|
|
367
|
+
const connected = [];
|
|
368
|
+
for (const cfg of list) {
|
|
369
|
+
let wrapper;
|
|
370
|
+
if (cfg.type === 'stdio') {
|
|
371
|
+
wrapper = new MCPClientWrapper({
|
|
372
|
+
transport: 'stdio',
|
|
373
|
+
command: cfg.command,
|
|
374
|
+
args: cfg.args ?? [],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
wrapper = new MCPClientWrapper({
|
|
379
|
+
transport: 'auto',
|
|
380
|
+
url: cfg.url,
|
|
381
|
+
headers: cfg.headers,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
await wrapper.connect();
|
|
385
|
+
connected.push(new McpClientAdapter(wrapper));
|
|
386
|
+
}
|
|
387
|
+
return connected;
|
|
388
|
+
}
|
|
389
|
+
export function buildMcpBridge(clients) {
|
|
390
|
+
return async (name, args, _signal) => {
|
|
391
|
+
const safeArgs = args != null && typeof args === 'object' && !Array.isArray(args)
|
|
392
|
+
? args
|
|
393
|
+
: {};
|
|
394
|
+
for (const client of clients) {
|
|
395
|
+
const listed = await client.listTools();
|
|
396
|
+
if (!listed.ok)
|
|
397
|
+
continue;
|
|
398
|
+
const owns = listed.value.some((t) => t.name === name);
|
|
399
|
+
if (!owns)
|
|
400
|
+
continue;
|
|
401
|
+
const result = await client.callTool(name, safeArgs);
|
|
402
|
+
if (!result.ok) {
|
|
403
|
+
return result.error.message;
|
|
404
|
+
}
|
|
405
|
+
const { content } = result.value;
|
|
406
|
+
return typeof content === 'string' ? content : JSON.stringify(content);
|
|
407
|
+
}
|
|
408
|
+
return `Tool not found: ${name}`;
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Raw-config mode routing gate (R6-F2)
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
/**
|
|
415
|
+
* Returns `true` ONLY when the raw coordinator config has an explicit `mode`
|
|
416
|
+
* string — the opt-in gate for the 18.0 Stepper runtime.
|
|
417
|
+
*
|
|
418
|
+
* IMPORTANT: Do NOT call `parseStepperCoordinatorConfig` to decide — that
|
|
419
|
+
* function defaults `mode` to `'planned-react'`, which would silently route
|
|
420
|
+
* every 17.0 legacy config onto the Stepper path. Gate on the RAW field
|
|
421
|
+
* presence only; parse is done INSIDE the Stepper branch after this check.
|
|
422
|
+
*/
|
|
423
|
+
export function usesStepper(coordCfg) {
|
|
424
|
+
if (!coordCfg)
|
|
425
|
+
return false;
|
|
426
|
+
// Explicit opt-in: `coordinator.mode` (preset alias) — a string in raw config.
|
|
427
|
+
if (typeof coordCfg.mode === 'string')
|
|
428
|
+
return true;
|
|
429
|
+
// OR an explicit `coordinator.flow` composition block (mode-less form). A flow
|
|
430
|
+
// with no mode is still a Stepper config; without this it would silently fall
|
|
431
|
+
// through to the plain smart pipeline.
|
|
432
|
+
if (typeof coordCfg.flow === 'object' && coordCfg.flow !== null)
|
|
433
|
+
return true;
|
|
434
|
+
// `coordinator.planner` without `coordinator.mode` selects the DAG coordinator
|
|
435
|
+
// variant (a RETAINED peer pipeline, not deprecated) — not the Stepper.
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
export class SmartServer {
|
|
439
|
+
cfg;
|
|
440
|
+
noop = () => { };
|
|
441
|
+
/**
|
|
442
|
+
* GLOBAL per-worker LLM/embedder cache. Populated lazily by `buildSubAgent`
|
|
443
|
+
* the first time each worker name is seen; subsequent per-session re-wires
|
|
444
|
+
* pull from this cache by reference (never reconstructing LLM clients).
|
|
445
|
+
*/
|
|
446
|
+
_workerLlmCache = new Map();
|
|
447
|
+
/** Lifecycle handle wired in `start()`; consumed by `_handle`. */
|
|
448
|
+
_lifecycle;
|
|
449
|
+
/** Hoisted globals used by `buildSessionAgent` to re-wire fresh per-session workers. */
|
|
450
|
+
_mainLlm;
|
|
451
|
+
_classifierLlm;
|
|
452
|
+
_helperLlm;
|
|
453
|
+
_fileLogger;
|
|
454
|
+
_mergedEmbedderFactories;
|
|
455
|
+
/**
|
|
456
|
+
* Captured DAG coordinator template — `deps` are stateless or LLM-bound and
|
|
457
|
+
* are reused across sessions; only `workers` + `stateOracle` are re-wired per
|
|
458
|
+
* session (review HIGH #1).
|
|
459
|
+
*/
|
|
460
|
+
_dagCoordinatorTemplate;
|
|
461
|
+
/**
|
|
462
|
+
* 18.0 Stepper coordinator handler — stateless, session context comes through
|
|
463
|
+
* `ctx.sessionId` at execute time. Built once in `start()` when
|
|
464
|
+
* `coordinator.mode` is present in the raw config; reused across sessions.
|
|
465
|
+
*/
|
|
466
|
+
_stepperCoordinatorHandler;
|
|
467
|
+
/**
|
|
468
|
+
* MCP clients connected for the Stepper path from the YAML `mcp:` config
|
|
469
|
+
* block. These are connected ONCE in `start()` (lazily resolved by
|
|
470
|
+
* `connectMcpClientsFromConfig`) and reused across every Stepper request.
|
|
471
|
+
*
|
|
472
|
+
* Populated only when `this.cfg.mcp` / `pipeline.mcp` is set AND no
|
|
473
|
+
* DI/plugin clients exist (DI precedence: `this.cfg.mcpClients` > plugin >
|
|
474
|
+
* yaml). Disposed via the server's `closeFns` on shutdown.
|
|
475
|
+
*/
|
|
476
|
+
_stepperMcpClients;
|
|
477
|
+
/**
|
|
478
|
+
* The ONE shared knowledge backend for the Stepper path (set during build).
|
|
479
|
+
* Held so DELETE /v1/sessions/:id can evict a session's entries from it —
|
|
480
|
+
* critical for the long-lived in-memory backend, which would otherwise retain
|
|
481
|
+
* knowledge after a delete and rehydrate it on a same-id re-entry.
|
|
482
|
+
*/
|
|
483
|
+
_stepperKnowledgeBackend;
|
|
484
|
+
/**
|
|
485
|
+
* Session meta-store for /v1/sessions endpoints (Task 17).
|
|
486
|
+
* Defaults to InMemorySessionMetaStore; a durable store can be injected via
|
|
487
|
+
* `cfg.sessionMetaStore` in a future extension.
|
|
488
|
+
*/
|
|
489
|
+
_sessionMetaStore = new InMemorySessionMetaStore();
|
|
490
|
+
constructor(config) {
|
|
491
|
+
this.cfg = config;
|
|
492
|
+
}
|
|
493
|
+
async start() {
|
|
494
|
+
const log = this.cfg.log ?? this.noop;
|
|
495
|
+
const fileLogger = {
|
|
496
|
+
log: (e) => log(e),
|
|
497
|
+
};
|
|
498
|
+
this._fileLogger = fileLogger;
|
|
499
|
+
const pipeline = this.cfg.pipeline;
|
|
500
|
+
// ---- Composition root: resolve config → interfaces --------------------
|
|
501
|
+
// LLM resolution — normalize flat/map cfg.llm and derive pipeline fallback.
|
|
502
|
+
const llmMap = normalizeLlmConfig(this.cfg.llm);
|
|
503
|
+
// Adapt pipeline.llm.main (uses `baseURL`) → SmartServerLlmConfig (uses
|
|
504
|
+
// `url`) so resolveLlmConfig's pipelineFallback parameter speaks one
|
|
505
|
+
// shape. Returns undefined when no pipeline.llm.main is configured.
|
|
506
|
+
const pipelineFallback = (() => {
|
|
507
|
+
const pm = pipeline?.llm?.main;
|
|
508
|
+
if (!pm)
|
|
509
|
+
return undefined;
|
|
510
|
+
return {
|
|
511
|
+
provider: pm.provider,
|
|
512
|
+
apiKey: pm.apiKey ?? '',
|
|
513
|
+
url: pm.baseURL,
|
|
514
|
+
model: pm.model,
|
|
515
|
+
temperature: pm.temperature,
|
|
516
|
+
};
|
|
517
|
+
})();
|
|
518
|
+
const topMain = resolveLlmConfig(llmMap, 'main', pipelineFallback);
|
|
519
|
+
const mainTemp = Number(pipeline?.llm?.main?.temperature ?? topMain?.temperature ?? 0.7);
|
|
520
|
+
const mainLlm = pipeline?.llm?.main
|
|
521
|
+
? await makeLlm(pipeline.llm.main, mainTemp)
|
|
522
|
+
: topMain
|
|
523
|
+
? await makeLlm({
|
|
524
|
+
provider: topMain.provider ?? 'deepseek',
|
|
525
|
+
apiKey: topMain.apiKey,
|
|
526
|
+
baseURL: topMain.url,
|
|
527
|
+
model: topMain.model,
|
|
528
|
+
}, mainTemp)
|
|
529
|
+
: (() => {
|
|
530
|
+
throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
|
|
531
|
+
})();
|
|
532
|
+
const classifierTemp = Number(pipeline?.llm?.classifier?.temperature ??
|
|
533
|
+
topMain?.classifierTemperature ??
|
|
534
|
+
0.1);
|
|
535
|
+
const classifierLlm = pipeline?.llm?.classifier
|
|
536
|
+
? await makeLlm(pipeline.llm.classifier, classifierTemp)
|
|
537
|
+
: pipeline?.llm?.main
|
|
538
|
+
? await makeLlm(pipeline.llm.main, classifierTemp)
|
|
539
|
+
: topMain
|
|
540
|
+
? await makeLlm({
|
|
541
|
+
provider: topMain.provider ?? 'deepseek',
|
|
542
|
+
apiKey: topMain.apiKey,
|
|
543
|
+
baseURL: topMain.url,
|
|
544
|
+
model: topMain.model,
|
|
545
|
+
}, classifierTemp)
|
|
546
|
+
: (() => {
|
|
547
|
+
throw new Error('no LLM configured: provide top-level llm.main or pipeline.llm.main');
|
|
548
|
+
})();
|
|
549
|
+
const helperLlm = pipeline?.llm?.helper
|
|
550
|
+
? await makeLlm(pipeline.llm.helper, Number(pipeline.llm.helper.temperature ?? 0.1))
|
|
551
|
+
: undefined;
|
|
552
|
+
this._mainLlm = mainLlm;
|
|
553
|
+
this._classifierLlm = classifierLlm;
|
|
554
|
+
this._helperLlm = helperLlm;
|
|
555
|
+
// ---- Plugin loader -------------------------------------------------------
|
|
556
|
+
const pluginLoader = this.cfg.pluginLoader ??
|
|
557
|
+
(() => {
|
|
558
|
+
const dirs = getDefaultPluginDirs();
|
|
559
|
+
if (this.cfg.pluginDir)
|
|
560
|
+
dirs.push(this.cfg.pluginDir);
|
|
561
|
+
return new FileSystemPluginLoader({
|
|
562
|
+
dirs,
|
|
563
|
+
log: (msg) => log({ event: 'plugin_loader', message: msg }),
|
|
564
|
+
});
|
|
565
|
+
})();
|
|
566
|
+
// Pre-load to extract embedder factories (needed before RAG resolution)
|
|
567
|
+
const plugins = await pluginLoader.load();
|
|
568
|
+
if (plugins.loadedFiles.length > 0) {
|
|
569
|
+
log({
|
|
570
|
+
event: 'plugins_loaded',
|
|
571
|
+
files: plugins.loadedFiles,
|
|
572
|
+
stageHandlers: [...plugins.stageHandlers.keys()],
|
|
573
|
+
embedderFactories: Object.keys(plugins.embedderFactories),
|
|
574
|
+
hasReranker: !!plugins.reranker,
|
|
575
|
+
hasQueryExpander: !!plugins.queryExpander,
|
|
576
|
+
hasOutputValidator: !!plugins.outputValidator,
|
|
577
|
+
mcpClients: plugins.mcpClients.length,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
if (plugins.errors.length > 0) {
|
|
581
|
+
log({ event: 'plugin_errors', errors: plugins.errors });
|
|
582
|
+
}
|
|
583
|
+
// Merge plugin embedder factories with config-provided ones
|
|
584
|
+
const mergedEmbedderFactories = {
|
|
585
|
+
...plugins.embedderFactories,
|
|
586
|
+
...this.cfg.embedderFactories, // config takes precedence over plugins
|
|
587
|
+
};
|
|
588
|
+
this._mergedEmbedderFactories = mergedEmbedderFactories;
|
|
589
|
+
// Resolve the embedder ONCE so the same instance feeds both makeRag and the
|
|
590
|
+
// subagent context-builder's toolSource (#137). See resolve-agent-embedder.
|
|
591
|
+
let resolvedEmbedder = await resolveAgentEmbedder(this.cfg.rag, this.cfg.embedder, mergedEmbedderFactories);
|
|
592
|
+
// ---- Build agent via Builder (interface-only) -------------------------
|
|
593
|
+
let builder = new SmartAgentBuilder({
|
|
594
|
+
mcp: pipeline?.mcp ?? this.cfg.mcp,
|
|
595
|
+
agent: this.cfg.agent,
|
|
596
|
+
prompts: this.cfg.prompts,
|
|
597
|
+
skipModelValidation: this.cfg.skipModelValidation,
|
|
598
|
+
})
|
|
599
|
+
.withMainLlm(mainLlm)
|
|
600
|
+
.withClassifierLlm(classifierLlm)
|
|
601
|
+
.withLogger(fileLogger)
|
|
602
|
+
.withMode(this.cfg.mode ?? 'smart');
|
|
603
|
+
if (helperLlm) {
|
|
604
|
+
builder = builder.withHelperLlm(helperLlm);
|
|
605
|
+
}
|
|
606
|
+
let toolsRag;
|
|
607
|
+
if (this.cfg.rag) {
|
|
608
|
+
const ragOptions = {
|
|
609
|
+
injectedEmbedder: resolvedEmbedder,
|
|
610
|
+
extraFactories: mergedEmbedderFactories,
|
|
611
|
+
};
|
|
612
|
+
toolsRag = await makeRag(this.cfg.rag, ragOptions);
|
|
613
|
+
builder = builder.setToolsRag(toolsRag);
|
|
614
|
+
builder = builder.setHistoryRag(await makeRag({ ...this.cfg.rag }, ragOptions));
|
|
615
|
+
}
|
|
616
|
+
// Wire pipeline.rag.{name} stores into the builder so multi-store YAML
|
|
617
|
+
// configs behave the same as the flat `rag:` block: `tools` and `history`
|
|
618
|
+
// map to the agent's built-in slots; everything else is registered as a
|
|
619
|
+
// named collection that the registry projection exposes via ragStores[name].
|
|
620
|
+
if (pipeline?.rag) {
|
|
621
|
+
for (const [name, storeCfg] of Object.entries(pipeline.rag)) {
|
|
622
|
+
// The `tools` store feeds the subagent context-builder's toolSource
|
|
623
|
+
// (mainEmbedder below). If the flat `rag:` block produced no embedder
|
|
624
|
+
// (YAML-only multi-store deployments), resolve one from this store's
|
|
625
|
+
// own config so the tools store and the context-builder share a single
|
|
626
|
+
// embedder instance (#141, mirrors the flat-path fix in #137). Other
|
|
627
|
+
// stores keep the raw DI embedder and are never overridden.
|
|
628
|
+
if (name === 'tools') {
|
|
629
|
+
resolvedEmbedder = await resolveToolsStoreEmbedder(resolvedEmbedder, storeCfg, this.cfg.embedder, mergedEmbedderFactories);
|
|
630
|
+
}
|
|
631
|
+
const ragOptions = {
|
|
632
|
+
injectedEmbedder: name === 'tools'
|
|
633
|
+
? (resolvedEmbedder ?? this.cfg.embedder)
|
|
634
|
+
: this.cfg.embedder,
|
|
635
|
+
extraFactories: mergedEmbedderFactories,
|
|
636
|
+
};
|
|
637
|
+
const rag = await makeRag(storeCfg, ragOptions);
|
|
638
|
+
if (name === 'tools') {
|
|
639
|
+
toolsRag = rag;
|
|
640
|
+
builder = builder.setToolsRag(rag);
|
|
641
|
+
}
|
|
642
|
+
else if (name === 'history') {
|
|
643
|
+
builder = builder.setHistoryRag(rag);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
builder = builder.addRagCollection({
|
|
647
|
+
name,
|
|
648
|
+
rag,
|
|
649
|
+
meta: { displayName: name, scope: 'global' },
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (this.cfg.circuitBreaker) {
|
|
655
|
+
builder = builder.withCircuitBreaker(this.cfg.circuitBreaker);
|
|
656
|
+
}
|
|
657
|
+
if (plugins.reranker) {
|
|
658
|
+
builder = builder.withReranker(plugins.reranker);
|
|
659
|
+
}
|
|
660
|
+
if (plugins.queryExpander) {
|
|
661
|
+
builder = builder.withQueryExpander(plugins.queryExpander);
|
|
662
|
+
}
|
|
663
|
+
if (plugins.outputValidator) {
|
|
664
|
+
builder = builder.withOutputValidator(plugins.outputValidator);
|
|
665
|
+
}
|
|
666
|
+
// Skill manager (DI > YAML config > plugin)
|
|
667
|
+
const skillManager = this.cfg.skillManager ??
|
|
668
|
+
plugins.skillManager ??
|
|
669
|
+
resolveSkillManager(this.cfg.skills);
|
|
670
|
+
if (skillManager) {
|
|
671
|
+
builder = builder.withSkillManager(skillManager);
|
|
672
|
+
}
|
|
673
|
+
// LLM call strategy (from agent config)
|
|
674
|
+
const strategyName = this.cfg.agent?.llmCallStrategy;
|
|
675
|
+
if (strategyName) {
|
|
676
|
+
const { StreamingLlmCallStrategy, NonStreamingLlmCallStrategy, FallbackLlmCallStrategy, } = await import('@mcp-abap-adt/llm-agent');
|
|
677
|
+
const strategies = {
|
|
678
|
+
streaming: () => new StreamingLlmCallStrategy(),
|
|
679
|
+
'non-streaming': () => new NonStreamingLlmCallStrategy(),
|
|
680
|
+
fallback: () => new FallbackLlmCallStrategy(fileLogger),
|
|
681
|
+
};
|
|
682
|
+
const factory = strategies[strategyName];
|
|
683
|
+
if (factory) {
|
|
684
|
+
builder = builder.withLlmCallStrategy(factory());
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Tool-selection strategy (from agent.toolSelection config)
|
|
688
|
+
const toolSelectionCfg = this.cfg.agent?.toolSelection;
|
|
689
|
+
if (toolSelectionCfg?.strategy) {
|
|
690
|
+
builder = builder.withToolSelectionStrategy(resolveToolSelectionStrategy(toolSelectionCfg.strategy, {
|
|
691
|
+
minScore: toolSelectionCfg.minScore,
|
|
692
|
+
}));
|
|
693
|
+
}
|
|
694
|
+
// MCP clients (DI > plugin; YAML fallback handled by builder)
|
|
695
|
+
const mcpClients = this.cfg.mcpClients ??
|
|
696
|
+
(plugins.mcpClients.length > 0 ? plugins.mcpClients : undefined);
|
|
697
|
+
if (mcpClients) {
|
|
698
|
+
builder = builder.withMcpClients(mcpClients);
|
|
699
|
+
}
|
|
700
|
+
// Client adapters (DI > plugin; ClineClientAdapter is always registered as default)
|
|
701
|
+
const { ClineClientAdapter } = await import('@mcp-abap-adt/llm-agent');
|
|
702
|
+
const adapterSources = [
|
|
703
|
+
...(this.cfg.clientAdapters ?? []),
|
|
704
|
+
...plugins.clientAdapters,
|
|
705
|
+
new ClineClientAdapter(),
|
|
706
|
+
];
|
|
707
|
+
for (const adapter of adapterSources) {
|
|
708
|
+
builder = builder.withClientAdapter(adapter);
|
|
709
|
+
}
|
|
710
|
+
// Build SubAgentRegistry from `subagents:` YAML block (if present).
|
|
711
|
+
// Each sub-agent is a minimal SmartAgent reusing the parent's plugin
|
|
712
|
+
// outputs (embedder factories, plugins) but with its own LLM/RAG/MCP/etc.
|
|
713
|
+
// Hoisted so the DAG branch below can reuse the same instances.
|
|
714
|
+
const registry = new Map();
|
|
715
|
+
if (this.cfg.subAgentConfigs && this.cfg.subAgentConfigs.length > 0) {
|
|
716
|
+
for (const sub of this.cfg.subAgentConfigs) {
|
|
717
|
+
const subAgent = await this.buildSubAgent(sub.name, sub.config, fileLogger, mergedEmbedderFactories);
|
|
718
|
+
registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
|
|
719
|
+
description: sub.description,
|
|
720
|
+
}));
|
|
721
|
+
log({
|
|
722
|
+
event: 'subagent_built',
|
|
723
|
+
name: sub.name,
|
|
724
|
+
hasDescription: typeof sub.description === 'string' && sub.description.length > 0,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
builder = builder.withSubAgents(registry);
|
|
728
|
+
}
|
|
729
|
+
// ---- Coordinator (autonomous plan-execute loop) ------------------------
|
|
730
|
+
const coordCfg = this.cfg.coordinatorYaml;
|
|
731
|
+
if (coordCfg) {
|
|
732
|
+
const rawCoordCfg = coordCfg;
|
|
733
|
+
if (usesStepper(rawCoordCfg)) {
|
|
734
|
+
// ── 18.0 Stepper path (opt-in via coordinator.mode) ──────────────────
|
|
735
|
+
// Parse only INSIDE this branch so parseStepperCoordinatorConfig's
|
|
736
|
+
// mode default ('planned-react') never fires for legacy configs (R6-F2).
|
|
737
|
+
const stepperCfg = parseStepperCoordinatorConfig(rawCoordCfg);
|
|
738
|
+
const logDir = this.cfg.logDir;
|
|
739
|
+
// KnowledgeRag factory: ONE backend instance shared across all requests.
|
|
740
|
+
// Both backends are keyed by sessionId internally, so a single instance
|
|
741
|
+
// gives correct per-session isolation AND persistence across same-cookie
|
|
742
|
+
// requests within the process. JsonlKnowledgeBackend (logDir set) adds
|
|
743
|
+
// durability across restarts; InMemoryKnowledgeBackend is the in-process
|
|
744
|
+
// default. (Previously a fresh InMemoryKnowledgeBackend was created per
|
|
745
|
+
// call, so subsequent same-session requests lost prior entries and were
|
|
746
|
+
// re-seeded — review fix.)
|
|
747
|
+
const knowledgeBackend = logDir
|
|
748
|
+
? new JsonlKnowledgeBackend(logDir)
|
|
749
|
+
: new InMemoryKnowledgeBackend();
|
|
750
|
+
// Held on the instance so DELETE /v1/sessions/:id can evict a session's
|
|
751
|
+
// knowledge from THIS backend (not just remove the JSONL file).
|
|
752
|
+
this._stepperKnowledgeBackend = knowledgeBackend;
|
|
753
|
+
const knowledgeRagFor = async (sessionId) => {
|
|
754
|
+
const kr = new KnowledgeRag(knowledgeBackend, sessionId);
|
|
755
|
+
// Seed deployment-supplied guidance into a BRAND-NEW session only
|
|
756
|
+
// (idempotent on resume). Config DATA — keeps the runtime MCP-agnostic.
|
|
757
|
+
await seedSessionKnowledge(kr, stepperCfg.knowledgeSeed, new Date().toISOString());
|
|
758
|
+
return kr;
|
|
759
|
+
};
|
|
760
|
+
// ToolsRag handle: real adapter over the server's tools RAG store + MCP
|
|
761
|
+
// catalog. Implements IToolsRagHandle for the Stepper runtime:
|
|
762
|
+
// query(text, k) — semantic search via toolsRag+embedder when available;
|
|
763
|
+
// catalog-order fallback when neither is present.
|
|
764
|
+
// lookup(name) — returns the tool schema from the MCP catalog by name
|
|
765
|
+
// (populated lazily on first query()).
|
|
766
|
+
//
|
|
767
|
+
// Catalog note: when DI/plugin clients are present they are already
|
|
768
|
+
// connected. When the config carries a YAML `mcp:` block instead, the
|
|
769
|
+
// builder connects those clients INSIDE builder.build() and never
|
|
770
|
+
// exposes them here. We therefore connect the YAML mcp config ONCE and
|
|
771
|
+
// cache the result in _stepperMcpClients so every subsequent Stepper
|
|
772
|
+
// request reuses the same live connections without reconnecting.
|
|
773
|
+
//
|
|
774
|
+
// DI precedence: this.cfg.mcpClients > plugin clients > yaml mcp config.
|
|
775
|
+
// NOTE: connection is NOT safe to invoke twice on the same wrapper, so
|
|
776
|
+
// the cache guard below is critical — do NOT move this inside a
|
|
777
|
+
// per-request factory.
|
|
778
|
+
if (!mcpClients && !this._stepperMcpClients) {
|
|
779
|
+
const yamlMcp = pipeline?.mcp ?? this.cfg.mcp;
|
|
780
|
+
this._stepperMcpClients = await connectMcpClientsFromConfig(yamlMcp);
|
|
781
|
+
}
|
|
782
|
+
const stepperMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
|
|
783
|
+
// Lazily-populated catalog: name → LlmTool.
|
|
784
|
+
let catalogCache;
|
|
785
|
+
const ensureCatalog = async () => {
|
|
786
|
+
if (catalogCache)
|
|
787
|
+
return catalogCache;
|
|
788
|
+
const catalog = new Map();
|
|
789
|
+
await Promise.allSettled(stepperMcpClients.map(async (client) => {
|
|
790
|
+
const result = await client.listTools();
|
|
791
|
+
if (result.ok) {
|
|
792
|
+
for (const t of result.value) {
|
|
793
|
+
if (!catalog.has(t.name)) {
|
|
794
|
+
catalog.set(t.name, t);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}));
|
|
799
|
+
catalogCache = catalog;
|
|
800
|
+
return catalog;
|
|
801
|
+
};
|
|
802
|
+
const toolsRagHandle = {
|
|
803
|
+
async query(text, k) {
|
|
804
|
+
const limit = k ?? 20;
|
|
805
|
+
const catalog = await ensureCatalog();
|
|
806
|
+
// Semantic path: requires both a vector store and an embedder.
|
|
807
|
+
if (toolsRag && resolvedEmbedder) {
|
|
808
|
+
const embedding = new QueryEmbedding(text, resolvedEmbedder);
|
|
809
|
+
const ragResult = await toolsRag.query(embedding, limit);
|
|
810
|
+
if (ragResult.ok) {
|
|
811
|
+
const hits = [];
|
|
812
|
+
for (const r of ragResult.value) {
|
|
813
|
+
const id = r.metadata.id;
|
|
814
|
+
if (id?.startsWith('tool:')) {
|
|
815
|
+
const name = id.slice(5).replace(/:.*$/, '');
|
|
816
|
+
const tool = catalog.get(name);
|
|
817
|
+
if (tool)
|
|
818
|
+
hits.push(tool);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (hits.length > 0)
|
|
822
|
+
return hits;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// Catalog-order fallback: return first `limit` tools from the MCP catalog.
|
|
826
|
+
return [...catalog.values()].slice(0, limit);
|
|
827
|
+
},
|
|
828
|
+
lookup(name) {
|
|
829
|
+
// Synchronous: returns from the in-memory cache populated by ensureCatalog.
|
|
830
|
+
// Returns undefined before the first query() call — callers that need
|
|
831
|
+
// a schema before any query should call query() first.
|
|
832
|
+
return catalogCache?.get(name);
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
// Mint helpers: stable UUIDs per spec §C.1/§C.2.
|
|
836
|
+
// The server wires randomUUID(); tests can inject deterministic minters.
|
|
837
|
+
const mintStepperId = () => randomUUID();
|
|
838
|
+
const mintTurnId = () => randomUUID();
|
|
839
|
+
const stepperMakeLlm = async (lc) => makeLlm({
|
|
840
|
+
provider: lc.provider ?? 'deepseek',
|
|
841
|
+
apiKey: lc.apiKey,
|
|
842
|
+
baseURL: lc.url,
|
|
843
|
+
model: lc.model,
|
|
844
|
+
}, Number(lc.temperature ?? mainTemp));
|
|
845
|
+
// Real callMcp bridge — delegates to the exported buildMcpBridge helper
|
|
846
|
+
// that iterates stepperMcpClients. Exported for testability (B-1).
|
|
847
|
+
const callMcp = buildMcpBridge(stepperMcpClients);
|
|
848
|
+
// DI/test override registry — left empty in production so buildStepperRoot
|
|
849
|
+
// builds the recursive child Steppers itself from `subagents` (below),
|
|
850
|
+
// sharing this run's role LLMs + shared token ledger.
|
|
851
|
+
const stepperRegistry = new Map();
|
|
852
|
+
// Declared subagents (name + description) → advertised to the planner and
|
|
853
|
+
// turned into recursive child Steppers in deep-stepper mode (Finding 1).
|
|
854
|
+
const stepperSubagents = (this.cfg.subAgentConfigs ?? []).map((s) => ({
|
|
855
|
+
name: s.name,
|
|
856
|
+
description: s.description,
|
|
857
|
+
}));
|
|
858
|
+
const stepperHandler = new StepperCoordinatorHandler({
|
|
859
|
+
buildBuilt: async (_ctx, logLlmCall) => {
|
|
860
|
+
// Per-RUN MCP result cache. Identical (tool, args) calls within ONE
|
|
861
|
+
// run reuse the first result instead of re-hitting MCP — fixes the
|
|
862
|
+
// redundant re-reads we saw in flow (gather → analyze → synthesize
|
|
863
|
+
// each fetching the same source/includes) and the latency that
|
|
864
|
+
// caused. Caching the Promise also dedups concurrent identical
|
|
865
|
+
// calls. Fresh per request → never stale across requests.
|
|
866
|
+
const runMcpCache = new Map();
|
|
867
|
+
const cachedCallMcp = (name, args, signal) => {
|
|
868
|
+
// Same case-normalised identity key as the executor's dedup, so
|
|
869
|
+
// the per-run cache and the dedup agree (and case-variant args —
|
|
870
|
+
// F01 vs f01 — hit the same entry).
|
|
871
|
+
const key = artifactIdentityKey(name, args);
|
|
872
|
+
const hit = runMcpCache.get(key);
|
|
873
|
+
if (hit)
|
|
874
|
+
return hit;
|
|
875
|
+
const p = callMcp(name, args, signal);
|
|
876
|
+
runMcpCache.set(key, p);
|
|
877
|
+
return p;
|
|
878
|
+
};
|
|
879
|
+
return buildStepperRoot({
|
|
880
|
+
coordCfg: rawCoordCfg,
|
|
881
|
+
registry: stepperRegistry,
|
|
882
|
+
subagents: stepperSubagents,
|
|
883
|
+
makeLlm: stepperMakeLlm,
|
|
884
|
+
knowledgeRagFor: (sid) => {
|
|
885
|
+
const backend = knowledgeBackend ?? new InMemoryKnowledgeBackend();
|
|
886
|
+
return new KnowledgeRag(backend, sid);
|
|
887
|
+
},
|
|
888
|
+
toolsRag: toolsRagHandle,
|
|
889
|
+
callMcp: cachedCallMcp,
|
|
890
|
+
mintStepperId,
|
|
891
|
+
llmMap,
|
|
892
|
+
pipelineFallback,
|
|
893
|
+
logLlmCall,
|
|
894
|
+
});
|
|
895
|
+
},
|
|
896
|
+
knowledgeRagFor,
|
|
897
|
+
toolsRag: toolsRagHandle,
|
|
898
|
+
mintStepperId,
|
|
899
|
+
mintTurnId,
|
|
900
|
+
});
|
|
901
|
+
this._stepperCoordinatorHandler = stepperHandler;
|
|
902
|
+
builder = builder.withStepperCoordinator(stepperHandler);
|
|
903
|
+
log({
|
|
904
|
+
event: 'stepper_coordinator_configured',
|
|
905
|
+
mode: stepperCfg.mode,
|
|
906
|
+
config: rawCoordCfg,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
else if (coordCfg.planner !== undefined) {
|
|
910
|
+
// ── DAG coordinator path (a RETAINED peer pipeline variant) ──────────
|
|
911
|
+
// `coordinator.planner` (no `coordinator.mode`) selects the DAG
|
|
912
|
+
// coordinator. It is NOT deprecated — it is the strongest single-shot
|
|
913
|
+
// plan-graph variant (e.g. unaided full-include reviews). The Stepper
|
|
914
|
+
// (`coordinator.mode` / `coordinator.flow`) is a sibling, not a
|
|
915
|
+
// replacement. Informational only.
|
|
916
|
+
log({
|
|
917
|
+
event: 'config_info',
|
|
918
|
+
message: 'coordinator.planner (no coordinator.mode) selects the DAG coordinator variant; ' +
|
|
919
|
+
'use coordinator.mode/flow for the Stepper variant',
|
|
920
|
+
});
|
|
921
|
+
// Fail loud if the config mixes DAG and linear fields.
|
|
922
|
+
assertCoordinatorConfigShape(rawCoordCfg);
|
|
923
|
+
// planner shape/type already validated by assertCoordinatorConfigShape.
|
|
924
|
+
// Validate interpreter.type if present — only 'dag' is supported.
|
|
925
|
+
const interpKind = coordCfg.interpreter?.type;
|
|
926
|
+
if (interpKind !== undefined && interpKind !== 'dag') {
|
|
927
|
+
throw new Error(`coordinator.interpreter: unknown type '${interpKind}' (only 'dag' is supported)`);
|
|
928
|
+
}
|
|
929
|
+
const built = await buildDagCoordinatorDeps({
|
|
930
|
+
coordCfg: rawCoordCfg,
|
|
931
|
+
llmMap,
|
|
932
|
+
pipelineFallback,
|
|
933
|
+
mainLlm,
|
|
934
|
+
helperLlm,
|
|
935
|
+
mainTemp,
|
|
936
|
+
registry,
|
|
937
|
+
makeLlm: async (lc) => makeLlm({
|
|
938
|
+
provider: lc.provider ?? 'deepseek',
|
|
939
|
+
apiKey: lc.apiKey,
|
|
940
|
+
baseURL: lc.url,
|
|
941
|
+
model: lc.model,
|
|
942
|
+
}, Number(lc.temperature ?? mainTemp)),
|
|
943
|
+
warn: (m) => log({ event: 'config_warning', message: m }),
|
|
944
|
+
});
|
|
945
|
+
// built is defined when coordCfg.planner !== undefined.
|
|
946
|
+
if (!built) {
|
|
947
|
+
throw new Error('internal: buildDagCoordinatorDeps returned undefined despite planner present');
|
|
948
|
+
}
|
|
949
|
+
const { oracleName, ...deps } = built;
|
|
950
|
+
builder = builder.withDagCoordinator(deps);
|
|
951
|
+
// Capture template for per-session re-wire (workers + stateOracle are
|
|
952
|
+
// re-wired per session; everything else is stateless or LLM-bound).
|
|
953
|
+
this._dagCoordinatorTemplate = {
|
|
954
|
+
deps: {
|
|
955
|
+
planner: deps.planner,
|
|
956
|
+
interpreter: deps.interpreter,
|
|
957
|
+
activation: deps.activation,
|
|
958
|
+
reviewer: deps.reviewer,
|
|
959
|
+
errorStrategy: deps.errorStrategy,
|
|
960
|
+
finalizer: deps.finalizer,
|
|
961
|
+
maxRoundTrips: deps.maxRoundTrips,
|
|
962
|
+
},
|
|
963
|
+
oracleName,
|
|
964
|
+
};
|
|
965
|
+
log({ event: 'dag_coordinator_configured', config: coordCfg });
|
|
966
|
+
}
|
|
967
|
+
else {
|
|
968
|
+
// ── Linear / flat coordinator path ────────────────────────────────────
|
|
969
|
+
// Fail loud if config mixes fields with non-linear settings.
|
|
970
|
+
assertCoordinatorConfigShape(rawCoordCfg);
|
|
971
|
+
// Linear mode: route plannerLlm through the normalized map chain.
|
|
972
|
+
// Priority: map[name] → 'helper'/'planner' alias (helperLlm) →
|
|
973
|
+
// pipelineFallback → mainLlm.
|
|
974
|
+
const linearPlannerName = coordCfg.plannerLlm;
|
|
975
|
+
// Role-resolution order:
|
|
976
|
+
// 1. explicit map[name] → build from that entry
|
|
977
|
+
// 2. name === 'helper' | 'planner' → reuse prebuilt helperLlm
|
|
978
|
+
// 3. unknown name → resolveLlmConfig fallback chain (map.main → pipelineFallback)
|
|
979
|
+
// 4. no name → mainLlm
|
|
980
|
+
const linearPlannerCfgStrict = resolveLlmConfigStrict(llmMap, linearPlannerName);
|
|
981
|
+
const plannerLlm = linearPlannerCfgStrict
|
|
982
|
+
? await makeLlm({
|
|
983
|
+
provider: linearPlannerCfgStrict.provider ?? 'deepseek',
|
|
984
|
+
apiKey: linearPlannerCfgStrict.apiKey,
|
|
985
|
+
baseURL: linearPlannerCfgStrict.url,
|
|
986
|
+
model: linearPlannerCfgStrict.model,
|
|
987
|
+
}, Number(linearPlannerCfgStrict.temperature ?? mainTemp))
|
|
988
|
+
: linearPlannerName === 'helper' || linearPlannerName === 'planner'
|
|
989
|
+
? (helperLlm ?? mainLlm)
|
|
990
|
+
: linearPlannerName
|
|
991
|
+
? // Unknown name, not an alias — try pipelineFallback last.
|
|
992
|
+
await (async () => {
|
|
993
|
+
const fb = resolveLlmConfig(llmMap, linearPlannerName, pipelineFallback);
|
|
994
|
+
return fb
|
|
995
|
+
? makeLlm({
|
|
996
|
+
provider: fb.provider ?? 'deepseek',
|
|
997
|
+
apiKey: fb.apiKey,
|
|
998
|
+
baseURL: fb.url,
|
|
999
|
+
model: fb.model,
|
|
1000
|
+
}, Number(fb.temperature ?? mainTemp))
|
|
1001
|
+
: mainLlm;
|
|
1002
|
+
})()
|
|
1003
|
+
: mainLlm;
|
|
1004
|
+
const planningKind = coordCfg.planning ?? 'one-shot';
|
|
1005
|
+
const dispatchKind = resolveCoordinatorDispatchKind(coordCfg.dispatch);
|
|
1006
|
+
// Build the same kind of context builder SmartAgentBuilder uses, so
|
|
1007
|
+
// YAML-configured deployments get the same default behavior. Uses the
|
|
1008
|
+
// embedder resolved above — DI-injected (this.cfg.embedder) when present,
|
|
1009
|
+
// otherwise constructed from `rag.embedder` (#137). Stays undefined for
|
|
1010
|
+
// bare in-memory BM25 stores (no embedder), in which case toolSource is
|
|
1011
|
+
// skipped and constrained subagents fall back to empty context.
|
|
1012
|
+
const mainEmbedder = resolvedEmbedder;
|
|
1013
|
+
const toolSource = mainEmbedder && toolsRag
|
|
1014
|
+
? async (text, k, signal) => {
|
|
1015
|
+
const embedding = new QueryEmbedding(text, mainEmbedder, {
|
|
1016
|
+
signal,
|
|
1017
|
+
});
|
|
1018
|
+
const r = await toolsRag.query(embedding, k, { signal });
|
|
1019
|
+
return r.ok ? r.value : [];
|
|
1020
|
+
}
|
|
1021
|
+
: undefined;
|
|
1022
|
+
const contextBuilder = new DefaultSubAgentContextBuilder({
|
|
1023
|
+
toolSource,
|
|
1024
|
+
});
|
|
1025
|
+
builder = builder.withCoordinator({
|
|
1026
|
+
planning: resolveCoordinatorPlanning(planningKind, plannerLlm),
|
|
1027
|
+
dispatch: resolveCoordinatorDispatch(dispatchKind, plannerLlm, contextBuilder),
|
|
1028
|
+
// Default to 'explicit' — the presence of a `coordinator:` block in
|
|
1029
|
+
// YAML is itself the opt-in signal. Users that want the auto-fallback
|
|
1030
|
+
// semantics (no subagents and no skill steps → tool-loop) must set
|
|
1031
|
+
// `activation: auto` explicitly.
|
|
1032
|
+
activation: resolveCoordinatorActivation(coordCfg.activation ?? 'explicit'),
|
|
1033
|
+
plannerLlm,
|
|
1034
|
+
maxSteps: coordCfg.maxSteps,
|
|
1035
|
+
maxRetriesPerStep: coordCfg.maxRetriesPerStep,
|
|
1036
|
+
failPolicy: coordCfg.failPolicy,
|
|
1037
|
+
});
|
|
1038
|
+
log({ event: 'coordinator_configured', config: coordCfg });
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const agentHandle = await builder.build();
|
|
1042
|
+
const { agent: smartAgent, chat, streamChat, close: closeAgent, circuitBreakers, ragStores, modelProvider, } = agentHandle;
|
|
1043
|
+
const { ragRegistry: globalRagRegistry, mcpClients: globalMcpClients } = agentHandle;
|
|
1044
|
+
// ---- API adapter map (built-in → config DI; DI wins) --------------------
|
|
1045
|
+
const { OpenAiApiAdapter, AnthropicApiAdapter } = await import('@mcp-abap-adt/llm-agent');
|
|
1046
|
+
const adapterMap = new Map();
|
|
1047
|
+
if (!this.cfg.disableBuiltInAdapters) {
|
|
1048
|
+
const openai = new OpenAiApiAdapter();
|
|
1049
|
+
const anthropic = new AnthropicApiAdapter();
|
|
1050
|
+
adapterMap.set(openai.name, openai);
|
|
1051
|
+
adapterMap.set(anthropic.name, anthropic);
|
|
1052
|
+
}
|
|
1053
|
+
if (this.cfg.apiAdapters) {
|
|
1054
|
+
for (const adapter of this.cfg.apiAdapters) {
|
|
1055
|
+
adapterMap.set(adapter.name, adapter);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const closeFns = [closeAgent];
|
|
1059
|
+
// Stepper-owned MCP clients (connected from YAML mcp: block when no
|
|
1060
|
+
// DI/plugin clients existed). Dispose on server shutdown.
|
|
1061
|
+
// TODO: IMcpClient does not currently expose a close() method; add
|
|
1062
|
+
// `for (const c of this._stepperMcpClients) await c.close?.();`
|
|
1063
|
+
// once the interface gains one.
|
|
1064
|
+
if (this._stepperMcpClients && this._stepperMcpClients.length > 0) {
|
|
1065
|
+
closeFns.push(async () => {
|
|
1066
|
+
this._stepperMcpClients = undefined;
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
// ---- Per-session lifecycle (cookie identity + graph factory + registry) ----
|
|
1070
|
+
const sessionCfg = this.cfg.session ?? {};
|
|
1071
|
+
const idleTtlMs = sessionCfg.idleTtlMs ?? 7_200_000;
|
|
1072
|
+
const lifecycle = buildSessionLifecycle({
|
|
1073
|
+
idleTtlMs,
|
|
1074
|
+
maxSessions: sessionCfg.maxSessions ?? 1000,
|
|
1075
|
+
cookieName: sessionCfg.cookieName ?? 'sid',
|
|
1076
|
+
mcpClients: globalMcpClients,
|
|
1077
|
+
toolsRag,
|
|
1078
|
+
ragRegistry: globalRagRegistry,
|
|
1079
|
+
buildAgent: (parts) => this.buildSessionAgent(parts),
|
|
1080
|
+
logger: fileLogger,
|
|
1081
|
+
});
|
|
1082
|
+
this._lifecycle = lifecycle;
|
|
1083
|
+
const sweepMs = Math.min(idleTtlMs, 60_000);
|
|
1084
|
+
const sweep = setInterval(() => {
|
|
1085
|
+
void lifecycle.evictIdle();
|
|
1086
|
+
}, sweepMs);
|
|
1087
|
+
sweep.unref?.();
|
|
1088
|
+
closeFns.push(async () => {
|
|
1089
|
+
clearInterval(sweep);
|
|
1090
|
+
await lifecycle.disposeAll();
|
|
1091
|
+
// Fix #21: per-session graphs may reference worker MCP clients, so
|
|
1092
|
+
// dispose them FIRST (above), THEN drain per-worker handle.close so the
|
|
1093
|
+
// worker-owned MCP clients themselves disconnect. Ordering matters —
|
|
1094
|
+
// closing MCP clients while a session graph is mid-use would cut its
|
|
1095
|
+
// request short.
|
|
1096
|
+
await drainWorkerCache(this._workerLlmCache);
|
|
1097
|
+
});
|
|
1098
|
+
const startTime = Date.now();
|
|
1099
|
+
const healthChecker = new HealthChecker({
|
|
1100
|
+
agent: smartAgent,
|
|
1101
|
+
startTime,
|
|
1102
|
+
version: this.cfg.version ?? PACKAGE_VERSION,
|
|
1103
|
+
circuitBreakers,
|
|
1104
|
+
});
|
|
1105
|
+
// Startup health check removed — use llm-agent-check CLI for diagnostics.
|
|
1106
|
+
// Running health check at startup wastes rate-limit budget when combined
|
|
1107
|
+
// with tool vectorization (146+ embedding calls).
|
|
1108
|
+
// ---- Config hot-reload (optional) ------------------------------------
|
|
1109
|
+
if (this.cfg.configFile) {
|
|
1110
|
+
const watcher = new ConfigWatcher(this.cfg.configFile);
|
|
1111
|
+
watcher.on('reload', (update) => {
|
|
1112
|
+
log({ event: 'config_reload', update });
|
|
1113
|
+
// Apply agent config updates
|
|
1114
|
+
const agentUpdate = {};
|
|
1115
|
+
if (update.maxIterations !== undefined)
|
|
1116
|
+
agentUpdate.maxIterations = update.maxIterations;
|
|
1117
|
+
if (update.maxToolCalls !== undefined)
|
|
1118
|
+
agentUpdate.maxToolCalls = update.maxToolCalls;
|
|
1119
|
+
if (update.ragQueryK !== undefined)
|
|
1120
|
+
agentUpdate.ragQueryK = update.ragQueryK;
|
|
1121
|
+
if (update.toolUnavailableTtlMs !== undefined)
|
|
1122
|
+
agentUpdate.toolUnavailableTtlMs = update.toolUnavailableTtlMs;
|
|
1123
|
+
if (update.showReasoning !== undefined)
|
|
1124
|
+
agentUpdate.showReasoning = update.showReasoning;
|
|
1125
|
+
if (update.historyAutoSummarizeLimit !== undefined)
|
|
1126
|
+
agentUpdate.historyAutoSummarizeLimit =
|
|
1127
|
+
update.historyAutoSummarizeLimit;
|
|
1128
|
+
if (update.prompts?.ragTranslate !== undefined)
|
|
1129
|
+
agentUpdate.ragTranslatePrompt = update.prompts.ragTranslate;
|
|
1130
|
+
if (update.prompts?.historySummary !== undefined)
|
|
1131
|
+
agentUpdate.historySummaryPrompt = update.prompts.historySummary;
|
|
1132
|
+
if (update.classificationEnabled !== undefined)
|
|
1133
|
+
agentUpdate.classificationEnabled = update.classificationEnabled;
|
|
1134
|
+
if (Object.keys(agentUpdate).length > 0) {
|
|
1135
|
+
smartAgent.applyConfigUpdate(agentUpdate);
|
|
1136
|
+
// Mirror onto `this.cfg.agent` so freshly-built session graphs
|
|
1137
|
+
// (which read `this.cfg.agent` in `buildSessionAgent`) observe the
|
|
1138
|
+
// update. Deep-merge to preserve untouched startup fields.
|
|
1139
|
+
// Note: `agentUpdate` includes flat fields ONLY whitelisted by
|
|
1140
|
+
// `AGENT_CONFIG_FIELDS` plus the two prompt fields, which we route
|
|
1141
|
+
// into `this.cfg.prompts` separately below.
|
|
1142
|
+
const agentPatch = {};
|
|
1143
|
+
for (const k of Object.keys(agentUpdate)) {
|
|
1144
|
+
if (k !== 'ragTranslatePrompt' && k !== 'historySummaryPrompt') {
|
|
1145
|
+
agentPatch[k] = agentUpdate[k];
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (Object.keys(agentPatch).length > 0) {
|
|
1149
|
+
const mergedAgent = {
|
|
1150
|
+
...(this.cfg.agent ??
|
|
1151
|
+
{}),
|
|
1152
|
+
...agentPatch,
|
|
1153
|
+
};
|
|
1154
|
+
this.cfg.agent =
|
|
1155
|
+
mergedAgent;
|
|
1156
|
+
}
|
|
1157
|
+
if (update.prompts?.ragTranslate !== undefined ||
|
|
1158
|
+
update.prompts?.historySummary !== undefined) {
|
|
1159
|
+
const mergedPrompts = {
|
|
1160
|
+
...(this.cfg.prompts ??
|
|
1161
|
+
{}),
|
|
1162
|
+
};
|
|
1163
|
+
if (update.prompts?.ragTranslate !== undefined) {
|
|
1164
|
+
mergedPrompts.ragTranslate = update.prompts.ragTranslate;
|
|
1165
|
+
}
|
|
1166
|
+
if (update.prompts?.historySummary !== undefined) {
|
|
1167
|
+
mergedPrompts.historySummary = update.prompts.historySummary;
|
|
1168
|
+
}
|
|
1169
|
+
this.cfg.prompts =
|
|
1170
|
+
mergedPrompts;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// Per-session graphs (built by SessionGraphFactory) captured the OLD
|
|
1174
|
+
// config and the OLD cached worker LLM set. Without invalidation,
|
|
1175
|
+
// existing sessions keep the stale SmartAgent and a fresh acquire on a
|
|
1176
|
+
// cookie-known sessionId still returns it. Clear the worker cache so
|
|
1177
|
+
// the next build reads from the just-applied config, then drop every
|
|
1178
|
+
// session graph. Failures are non-fatal — log and continue.
|
|
1179
|
+
// Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing
|
|
1180
|
+
// the cache. Hot-reload runs from a synchronous emitter callback, so
|
|
1181
|
+
// fire-and-forget here — same async-tolerance as the invalidateAll
|
|
1182
|
+
// call below.
|
|
1183
|
+
drainWorkerCache(this._workerLlmCache).catch((err) => {
|
|
1184
|
+
log({ event: 'config_reload_drain_error', error: String(err) });
|
|
1185
|
+
});
|
|
1186
|
+
this._lifecycle?.invalidateAll().catch((err) => {
|
|
1187
|
+
log({ event: 'config_reload_invalidate_error', error: String(err) });
|
|
1188
|
+
});
|
|
1189
|
+
// Apply RAG weight updates
|
|
1190
|
+
if (update.vectorWeight !== undefined ||
|
|
1191
|
+
update.keywordWeight !== undefined) {
|
|
1192
|
+
for (const store of Object.values(ragStores)) {
|
|
1193
|
+
if (store &&
|
|
1194
|
+
typeof store.updateWeights === 'function') {
|
|
1195
|
+
store.updateWeights({
|
|
1196
|
+
vectorWeight: update.vectorWeight,
|
|
1197
|
+
keywordWeight: update.keywordWeight,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
watcher.on('error', (err) => {
|
|
1204
|
+
log({ event: 'config_reload_error', error: String(err) });
|
|
1205
|
+
});
|
|
1206
|
+
watcher.start();
|
|
1207
|
+
closeFns.push(() => watcher.stop());
|
|
1208
|
+
}
|
|
1209
|
+
const { requestLogger } = agentHandle;
|
|
1210
|
+
const server = http.createServer((req, res) => this._handle(req, res, requestLogger, smartAgent, chat, streamChat, log, healthChecker, modelProvider, adapterMap).catch((err) => {
|
|
1211
|
+
if (!res.headersSent) {
|
|
1212
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1213
|
+
res.end(jsonError(String(err), 'server_error'));
|
|
1214
|
+
}
|
|
1215
|
+
}));
|
|
1216
|
+
return new Promise((resolve, reject) => {
|
|
1217
|
+
const port = this.cfg.port ?? 4004;
|
|
1218
|
+
const host = this.cfg.host ?? '0.0.0.0';
|
|
1219
|
+
server.on('error', reject);
|
|
1220
|
+
server.listen(port, host, () => {
|
|
1221
|
+
const addr = server.address();
|
|
1222
|
+
const actualPort = typeof addr === 'object' && addr !== null ? addr.port : port;
|
|
1223
|
+
log({ event: 'server_started', port: actualPort, host });
|
|
1224
|
+
resolve({
|
|
1225
|
+
port: actualPort,
|
|
1226
|
+
close: async () => {
|
|
1227
|
+
// 1. Stop accepting new connections AND wait for in-flight HTTP
|
|
1228
|
+
// requests to drain. Until server.close() resolves, requests
|
|
1229
|
+
// accepted before shutdown may still be running and pinning
|
|
1230
|
+
// per-session graphs — disposing those graphs first would
|
|
1231
|
+
// violate the active-request pinning guarantee.
|
|
1232
|
+
await new Promise((res, rej) => server.close((e) => (e ? rej(e) : res())));
|
|
1233
|
+
// 2. Now run lifecycle cleanup: sweep timer, lifecycle.disposeAll,
|
|
1234
|
+
// config watcher stop, agent close. By this point no HTTP
|
|
1235
|
+
// request is in flight, so disposing session graphs is safe.
|
|
1236
|
+
for (const fn of closeFns)
|
|
1237
|
+
await fn();
|
|
1238
|
+
},
|
|
1239
|
+
requestLogger,
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Build a `SmartAgent` instance from a nested sub-agent config.
|
|
1246
|
+
*
|
|
1247
|
+
* Mirrors the parent's composition flow but intentionally narrower:
|
|
1248
|
+
* - reuses the parent's merged embedder factories so plugin-provided
|
|
1249
|
+
* embedders stay available without a second `pluginLoader.load()`;
|
|
1250
|
+
* - shares the parent's file logger to keep one log stream;
|
|
1251
|
+
* - skips features that don't make sense for a nested agent (HTTP
|
|
1252
|
+
* surface, plugin reranker/queryExpander/outputValidator, MCP
|
|
1253
|
+
* client DI, custom client adapters, structured pipeline rag.*
|
|
1254
|
+
* stores). Only the flat `rag:` block is honoured.
|
|
1255
|
+
*
|
|
1256
|
+
* Sub-agents do not recurse — `subagents:` inside a sub-YAML is
|
|
1257
|
+
* rejected at config-parse time, so `subCfg.subAgentConfigs` is
|
|
1258
|
+
* always undefined here.
|
|
1259
|
+
*/
|
|
1260
|
+
async buildSubAgent(name, subCfg, parentLogger, embedderFactories, injected) {
|
|
1261
|
+
// Normalize subagent llm: either flat { provider, apiKey, ... } or a map
|
|
1262
|
+
// { main: {...}, planner: {...} }. normalizeLlmConfig wraps flat shape as
|
|
1263
|
+
// { main: flat } so downstream code always reads from .main.
|
|
1264
|
+
const subLlmMap = normalizeLlmConfig(subCfg.llm);
|
|
1265
|
+
const subLlmMain = subLlmMap?.main;
|
|
1266
|
+
if (!subLlmMain?.apiKey &&
|
|
1267
|
+
subLlmMain?.provider !== 'sap-ai-sdk' &&
|
|
1268
|
+
subLlmMain?.provider !== 'ollama' &&
|
|
1269
|
+
!subCfg.pipeline?.llm?.main) {
|
|
1270
|
+
throw new Error(`subagent '${name}': LLM API key is required`);
|
|
1271
|
+
}
|
|
1272
|
+
const subPipeline = subCfg.pipeline;
|
|
1273
|
+
// LLM/embedder clients: when the per-session re-wire injected them, use
|
|
1274
|
+
// those cached instances by reference (NEVER reconstruct). Otherwise (the
|
|
1275
|
+
// primary build()), build-once via the cache so the global agent build
|
|
1276
|
+
// also populates it and later per-session re-wires reuse the SAME
|
|
1277
|
+
// instances.
|
|
1278
|
+
// Note: a per-worker embedder slot is carried in WorkerLlmSet and the
|
|
1279
|
+
// injected record for forward-compat with Task A8/A10 per-session wiring.
|
|
1280
|
+
// Today's buildSubAgent does not separately resolve an embedder here —
|
|
1281
|
+
// embedders are carried by the worker's own store via makeRag's
|
|
1282
|
+
// `injectedEmbedder` — so we ignore the embedder field below.
|
|
1283
|
+
// Resolve (build-once or load from cache) the worker's own LLMs +
|
|
1284
|
+
// toolsRag/historyRag/mcpClients. The cache is keyed by worker name; the
|
|
1285
|
+
// primary build() populates it (no `injected` arg), and per-session
|
|
1286
|
+
// re-wires (`injected` set) read from it via the same call below — the
|
|
1287
|
+
// cache hit short-circuits all factories. This keeps the worker's
|
|
1288
|
+
// declared RAG/MCP intact across per-session re-wires (review HIGH #1).
|
|
1289
|
+
const subFlatLlm = subLlmMain;
|
|
1290
|
+
const mainTemp = Number(subPipeline?.llm?.main?.temperature ?? subFlatLlm?.temperature ?? 0.7);
|
|
1291
|
+
const classifierTemp = Number(subPipeline?.llm?.classifier?.temperature ??
|
|
1292
|
+
subFlatLlm?.classifierTemperature ??
|
|
1293
|
+
0.1);
|
|
1294
|
+
const cached = await resolveWorkerLlmSet({
|
|
1295
|
+
name,
|
|
1296
|
+
cache: this._workerLlmCache,
|
|
1297
|
+
// Preserve the existing makeLlm derivation exactly.
|
|
1298
|
+
makeMain: () => subPipeline?.llm?.main
|
|
1299
|
+
? makeLlm(subPipeline.llm.main, mainTemp)
|
|
1300
|
+
: makeLlm({
|
|
1301
|
+
// ?? 'deepseek' is a TS type-narrowing net only; the config
|
|
1302
|
+
// validator rejects a missing flat-schema provider before
|
|
1303
|
+
// this runs.
|
|
1304
|
+
provider: subFlatLlm?.provider ?? 'deepseek',
|
|
1305
|
+
apiKey: subFlatLlm?.apiKey ?? '',
|
|
1306
|
+
baseURL: subFlatLlm?.url,
|
|
1307
|
+
model: subFlatLlm?.model,
|
|
1308
|
+
}, mainTemp),
|
|
1309
|
+
makeClassifier: () => subPipeline?.llm?.classifier
|
|
1310
|
+
? makeLlm(subPipeline.llm.classifier, classifierTemp)
|
|
1311
|
+
: subPipeline?.llm?.main
|
|
1312
|
+
? makeLlm(subPipeline.llm.main, classifierTemp)
|
|
1313
|
+
: makeLlm({
|
|
1314
|
+
provider: subFlatLlm?.provider ?? 'deepseek',
|
|
1315
|
+
apiKey: subFlatLlm?.apiKey ?? '',
|
|
1316
|
+
baseURL: subFlatLlm?.url,
|
|
1317
|
+
model: subFlatLlm?.model,
|
|
1318
|
+
}, classifierTemp),
|
|
1319
|
+
makeHelper: subPipeline?.llm?.helper
|
|
1320
|
+
? ((h) => () => makeLlm(h, Number(h.temperature ?? 0.1)))(subPipeline.llm.helper)
|
|
1321
|
+
: undefined,
|
|
1322
|
+
// Worker-OWN tools RAG (from subCfg.rag, if declared). Built once;
|
|
1323
|
+
// re-wired per-session by reference — never re-vectorized.
|
|
1324
|
+
makeToolsRag: subCfg.rag
|
|
1325
|
+
? () => makeRag(subCfg.rag, {
|
|
1326
|
+
injectedEmbedder: subCfg.embedder,
|
|
1327
|
+
extraFactories: embedderFactories,
|
|
1328
|
+
})
|
|
1329
|
+
: undefined,
|
|
1330
|
+
makeHistoryRag: subCfg.rag
|
|
1331
|
+
? () => makeRag({ ...subCfg.rag }, {
|
|
1332
|
+
injectedEmbedder: subCfg.embedder,
|
|
1333
|
+
extraFactories: embedderFactories,
|
|
1334
|
+
})
|
|
1335
|
+
: undefined,
|
|
1336
|
+
// Worker-OWN MCP clients. DI list (subCfg.mcpClients) wins; otherwise
|
|
1337
|
+
// SmartAgentBuilder's own MCP-connect path handles `subCfg.mcp` — we
|
|
1338
|
+
// don't pre-build those here (connection is the builder's job and is
|
|
1339
|
+
// not safe to invoke twice). The cache stores the DI clients only.
|
|
1340
|
+
makeMcpClients: subCfg.mcpClients && subCfg.mcpClients.length > 0
|
|
1341
|
+
? async () => subCfg.mcpClients
|
|
1342
|
+
: undefined,
|
|
1343
|
+
});
|
|
1344
|
+
const mainLlm = cached.mainLlm;
|
|
1345
|
+
const classifierLlm = cached.classifierLlm;
|
|
1346
|
+
const helperLlm = cached.helperLlm;
|
|
1347
|
+
let subBuilder = new SmartAgentBuilder({
|
|
1348
|
+
mcp: subPipeline?.mcp ?? subCfg.mcp,
|
|
1349
|
+
agent: subCfg.agent,
|
|
1350
|
+
prompts: subCfg.prompts,
|
|
1351
|
+
skipModelValidation: subCfg.skipModelValidation,
|
|
1352
|
+
})
|
|
1353
|
+
.withMainLlm(mainLlm)
|
|
1354
|
+
.withClassifierLlm(classifierLlm)
|
|
1355
|
+
.withLogger(parentLogger)
|
|
1356
|
+
.withMode(subCfg.mode ?? 'smart');
|
|
1357
|
+
if (helperLlm) {
|
|
1358
|
+
subBuilder = subBuilder.withHelperLlm(helperLlm);
|
|
1359
|
+
}
|
|
1360
|
+
// SHARE the parent RAG registry + session logger when injected (per-session
|
|
1361
|
+
// worker re-wire). The per-call scope filter isolates by ctx.sessionId.
|
|
1362
|
+
const sharedReg = resolveSubAgentRagRegistry({
|
|
1363
|
+
parentRagRegistry: injected?.ragRegistry,
|
|
1364
|
+
});
|
|
1365
|
+
if (sharedReg)
|
|
1366
|
+
subBuilder = subBuilder.setRagRegistry(sharedReg);
|
|
1367
|
+
if (injected?.requestLogger) {
|
|
1368
|
+
subBuilder = subBuilder.withRequestLogger(injected.requestLogger);
|
|
1369
|
+
}
|
|
1370
|
+
// Tools/History RAG priority (review HIGH #1):
|
|
1371
|
+
// 1) worker's OWN cached toolsRag (from subCfg.rag) — built once, reused
|
|
1372
|
+
// by reference across per-session re-wires (never re-vectorized);
|
|
1373
|
+
// 2) parent's injected toolsRag (fallback for workers that did not
|
|
1374
|
+
// declare their own store).
|
|
1375
|
+
// History RAG: only when the worker has its own cached instance — the
|
|
1376
|
+
// parent's history RAG is owned by the parent agent and is not shared.
|
|
1377
|
+
if (cached.toolsRag) {
|
|
1378
|
+
subBuilder = subBuilder.setToolsRag(cached.toolsRag);
|
|
1379
|
+
if (cached.historyRag) {
|
|
1380
|
+
subBuilder = subBuilder.setHistoryRag(cached.historyRag);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
else if (injected?.toolsRag) {
|
|
1384
|
+
subBuilder = subBuilder.setToolsRag(injected.toolsRag);
|
|
1385
|
+
}
|
|
1386
|
+
if (subCfg.skillManager) {
|
|
1387
|
+
subBuilder = subBuilder.withSkillManager(subCfg.skillManager);
|
|
1388
|
+
}
|
|
1389
|
+
// MCP clients priority (review HIGH #1):
|
|
1390
|
+
// 1) worker's OWN cached MCP clients (from subCfg.mcpClients DI) — keeps
|
|
1391
|
+
// the worker pointed at its own upstream when the parent has none;
|
|
1392
|
+
// 2) parent's injected GLOBAL MCP clients (fallback) — skips re-connect.
|
|
1393
|
+
// If neither is set, fall through to the builder's own MCP-connect path
|
|
1394
|
+
// (which honours `subCfg.mcp`).
|
|
1395
|
+
if (cached.mcpClients && cached.mcpClients.length > 0) {
|
|
1396
|
+
subBuilder = subBuilder.withMcpClients(cached.mcpClients);
|
|
1397
|
+
}
|
|
1398
|
+
else if (injected?.mcpClients && injected.mcpClients.length > 0) {
|
|
1399
|
+
subBuilder = subBuilder.withMcpClients(injected.mcpClients);
|
|
1400
|
+
}
|
|
1401
|
+
const handle = await subBuilder.build();
|
|
1402
|
+
// Backfill the per-worker cache from the BUILT handle (review HIGH #7).
|
|
1403
|
+
// Only runs on the primary build path (no `injected`) so per-session
|
|
1404
|
+
// re-wires never overwrite the cache. See backfillWorkerCacheFromHandle's
|
|
1405
|
+
// doc-comment for the rationale.
|
|
1406
|
+
if (!injected) {
|
|
1407
|
+
const entry = this._workerLlmCache.get(name);
|
|
1408
|
+
if (entry)
|
|
1409
|
+
await backfillWorkerCacheFromHandle(entry, handle);
|
|
1410
|
+
}
|
|
1411
|
+
return handle.agent;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Builds a per-session SmartAgent from injected globals (review HIGH #1 +
|
|
1415
|
+
* MEDIUM #3). Re-wires the SubAgent registry + DAG coordinator deps FRESH per
|
|
1416
|
+
* session, sharing this session's logger + the global ragRegistry/toolsRag/
|
|
1417
|
+
* mcpClients + the CACHED per-worker LLM/embedder (this._workerLlmCache). It
|
|
1418
|
+
* NEVER reuses the primary build()'s global `registry`/DAG-deps and NEVER
|
|
1419
|
+
* constructs new LLM clients.
|
|
1420
|
+
*/
|
|
1421
|
+
async buildSessionAgent(parts) {
|
|
1422
|
+
// Guard: globals must already be captured by the primary build() before any
|
|
1423
|
+
// session graph is built (the registry calls this lazily on first acquire).
|
|
1424
|
+
if (!this._mainLlm || !this._classifierLlm || !this._fileLogger) {
|
|
1425
|
+
throw new Error('buildSessionAgent invoked before primary build() captured globals');
|
|
1426
|
+
}
|
|
1427
|
+
let b = new SmartAgentBuilder({
|
|
1428
|
+
agent: this.cfg.agent,
|
|
1429
|
+
prompts: this.cfg.prompts,
|
|
1430
|
+
skipModelValidation: this.cfg.skipModelValidation,
|
|
1431
|
+
})
|
|
1432
|
+
.withMainLlm(this._mainLlm)
|
|
1433
|
+
.withClassifierLlm(this._classifierLlm)
|
|
1434
|
+
.withLogger(this._fileLogger)
|
|
1435
|
+
.withMode(this.cfg.mode ?? 'smart')
|
|
1436
|
+
.withMcpClients(parts.mcpClients) // skips connect + re-vectorize
|
|
1437
|
+
.setRagRegistry(parts.ragRegistry)
|
|
1438
|
+
.withRequestLogger(parts.logger); // per-session token-logger
|
|
1439
|
+
if (this._helperLlm)
|
|
1440
|
+
b = b.withHelperLlm(this._helperLlm);
|
|
1441
|
+
if (parts.toolsRag)
|
|
1442
|
+
b = b.setToolsRag(parts.toolsRag);
|
|
1443
|
+
// FRESH per-session workers — re-wire the registry from the SAME subagent
|
|
1444
|
+
// configs the primary build() used, injecting globals + this session's
|
|
1445
|
+
// logger + the CACHED per-worker LLM/embedder. No LLM reconstruction.
|
|
1446
|
+
if (this.cfg.subAgentConfigs && this.cfg.subAgentConfigs.length > 0) {
|
|
1447
|
+
const registry = new Map();
|
|
1448
|
+
for (const sub of this.cfg.subAgentConfigs) {
|
|
1449
|
+
// Lazy build-on-miss (Fix #18). After PUT /v1/config or hot-reload
|
|
1450
|
+
// clears `_workerLlmCache`, the next buildSessionAgent used to throw
|
|
1451
|
+
// "worker LLM set not cached" because the cache was assumed
|
|
1452
|
+
// pre-populated by the primary build(). buildSubAgent itself routes
|
|
1453
|
+
// through `resolveWorkerLlmSet` which is build-on-miss, so calling
|
|
1454
|
+
// it without an `injected` arg rebuilds the cache entry. We then
|
|
1455
|
+
// re-read the entry to honour the per-worker slot priority below.
|
|
1456
|
+
if (!this._workerLlmCache.has(sub.name)) {
|
|
1457
|
+
await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {});
|
|
1458
|
+
}
|
|
1459
|
+
const cached = this._workerLlmCache.get(sub.name);
|
|
1460
|
+
if (!cached) {
|
|
1461
|
+
// Defence in depth — should be impossible after the lazy build
|
|
1462
|
+
// above unless buildSubAgent's contract changes.
|
|
1463
|
+
throw new Error(`worker LLM set not cached for '${sub.name}'`);
|
|
1464
|
+
}
|
|
1465
|
+
// Per-worker injected slot priority (review HIGH #7):
|
|
1466
|
+
// worker-cached (from the primary build, includes backfilled
|
|
1467
|
+
// subCfg.mcp / subCfg.rag results) → parent's session-scoped
|
|
1468
|
+
// fallback. Encoded HERE so buildSubAgent does not need to know
|
|
1469
|
+
// the difference; it just consumes injected.mcpClients/toolsRag.
|
|
1470
|
+
const injectedMcpClients = cached.mcpClients && cached.mcpClients.length > 0
|
|
1471
|
+
? cached.mcpClients
|
|
1472
|
+
: parts.mcpClients;
|
|
1473
|
+
const injectedToolsRag = cached.toolsRag ?? parts.toolsRag;
|
|
1474
|
+
const subAgent = await this.buildSubAgent(sub.name, sub.config, this._fileLogger, this._mergedEmbedderFactories ?? {}, {
|
|
1475
|
+
ragRegistry: parts.ragRegistry,
|
|
1476
|
+
toolsRag: injectedToolsRag,
|
|
1477
|
+
mcpClients: injectedMcpClients,
|
|
1478
|
+
requestLogger: parts.logger,
|
|
1479
|
+
mainLlm: cached.mainLlm,
|
|
1480
|
+
classifierLlm: cached.classifierLlm,
|
|
1481
|
+
helperLlm: cached.helperLlm,
|
|
1482
|
+
embedder: cached.embedder,
|
|
1483
|
+
});
|
|
1484
|
+
registry.set(sub.name, new SmartAgentSubAgent(sub.name, subAgent, {
|
|
1485
|
+
description: sub.description,
|
|
1486
|
+
}));
|
|
1487
|
+
}
|
|
1488
|
+
b = b.withSubAgents(registry);
|
|
1489
|
+
if (this._stepperCoordinatorHandler) {
|
|
1490
|
+
// Stepper coordinator is stateless — reuse the same handler instance
|
|
1491
|
+
// across sessions (session context arrives via ctx.sessionId).
|
|
1492
|
+
b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
|
|
1493
|
+
}
|
|
1494
|
+
else if (this._dagCoordinatorTemplate) {
|
|
1495
|
+
const tpl = this._dagCoordinatorTemplate;
|
|
1496
|
+
const workers = new Map([...registry].filter(([name]) => name !== tpl.oracleName));
|
|
1497
|
+
const raw = tpl.oracleName ? registry.get(tpl.oracleName) : undefined;
|
|
1498
|
+
b = b.withDagCoordinator({
|
|
1499
|
+
...tpl.deps,
|
|
1500
|
+
workers,
|
|
1501
|
+
stateOracle: raw ? new SubAgentStateOracle(raw) : undefined,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
else if (this._stepperCoordinatorHandler) {
|
|
1506
|
+
// No sub-agent configs but stepper coordinator configured — wire it directly.
|
|
1507
|
+
// (Stepper coordinator is stateless and works without sub-agents.)
|
|
1508
|
+
b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
|
|
1509
|
+
}
|
|
1510
|
+
const handle = await b.build();
|
|
1511
|
+
return handle.agent;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Resolve identity (mint cookie when needed), acquire the per-session graph,
|
|
1515
|
+
* run `fn` pinned, and release in `finally`. Order in `finally`:
|
|
1516
|
+
* 1. drop the per-traceId logger delta (server-owned free, review HIGH #2)
|
|
1517
|
+
* 2. release the refcount pin
|
|
1518
|
+
*/
|
|
1519
|
+
async _withSession(req, res, fn) {
|
|
1520
|
+
const lifecycle = this._lifecycle;
|
|
1521
|
+
if (!lifecycle) {
|
|
1522
|
+
throw new Error('SmartServer lifecycle not initialized');
|
|
1523
|
+
}
|
|
1524
|
+
const traceId = randomUUID();
|
|
1525
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1526
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1527
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1528
|
+
const sessionId = resolved.identity.sessionId;
|
|
1529
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1530
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1531
|
+
}
|
|
1532
|
+
const graph = await lifecycle.acquire(sessionId);
|
|
1533
|
+
// Register/touch the session in the meta store so /v1/sessions, resume and
|
|
1534
|
+
// delete reflect real chat/stream traffic (review Finding 3). Best-effort:
|
|
1535
|
+
// a meta-store hiccup must never break the actual request.
|
|
1536
|
+
try {
|
|
1537
|
+
await recordSessionStart(this._sessionMetaStore, sessionId, new Date().toISOString());
|
|
1538
|
+
}
|
|
1539
|
+
catch {
|
|
1540
|
+
// swallow — session metadata is non-critical to serving the request
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
await fn(graph, sessionId, traceId);
|
|
1544
|
+
}
|
|
1545
|
+
finally {
|
|
1546
|
+
try {
|
|
1547
|
+
await recordSessionEnd(this._sessionMetaStore, sessionId, new Date().toISOString());
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
// swallow — see above
|
|
1551
|
+
}
|
|
1552
|
+
graph.logger.dropRequest(traceId);
|
|
1553
|
+
// Pass the graph instance — `invalidateAll()` may have detached this
|
|
1554
|
+
// graph into the draining map while the request was in flight; we must
|
|
1555
|
+
// release THIS specific instance, not whatever currently lives under
|
|
1556
|
+
// `sessionId` in the registry.
|
|
1557
|
+
lifecycle.release(sessionId, graph);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
async _handle(req, res, requestLogger, smartAgent, chat, streamChat, log, healthChecker, modelProvider, adapterMap) {
|
|
1561
|
+
const rawUrl = req.url ?? '/';
|
|
1562
|
+
const urlPath = rawUrl.split('?')[0].replace(/\/$/, '') || '/';
|
|
1563
|
+
for (const [k, v] of Object.entries(CORS_HEADERS))
|
|
1564
|
+
res.setHeader(k, v);
|
|
1565
|
+
if (req.method === 'OPTIONS') {
|
|
1566
|
+
res.writeHead(204);
|
|
1567
|
+
res.end();
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
log({
|
|
1571
|
+
event: 'http_request',
|
|
1572
|
+
method: req.method,
|
|
1573
|
+
url: rawUrl,
|
|
1574
|
+
normalizedPath: urlPath,
|
|
1575
|
+
});
|
|
1576
|
+
if (req.method === 'GET' &&
|
|
1577
|
+
(urlPath === '/v1/models' || urlPath === '/models')) {
|
|
1578
|
+
const queryString = rawUrl.includes('?') ? rawUrl.split('?')[1] : '';
|
|
1579
|
+
const queryParams = new URLSearchParams(queryString);
|
|
1580
|
+
const excludeEmbedding = queryParams.get('exclude_embedding') === 'true';
|
|
1581
|
+
let data = [
|
|
1582
|
+
{ id: 'smart-agent', object: 'model', owned_by: 'smart-agent' },
|
|
1583
|
+
];
|
|
1584
|
+
if (modelProvider) {
|
|
1585
|
+
const result = await modelProvider.getModels({ excludeEmbedding });
|
|
1586
|
+
if (result.ok) {
|
|
1587
|
+
data = result.value.map((m) => ({
|
|
1588
|
+
id: m.id,
|
|
1589
|
+
object: 'model',
|
|
1590
|
+
owned_by: m.owned_by ?? 'unknown',
|
|
1591
|
+
...(m.displayName ? { display_name: m.displayName } : {}),
|
|
1592
|
+
...(m.provider ? { provider: m.provider } : {}),
|
|
1593
|
+
...(m.capabilities ? { capabilities: m.capabilities } : {}),
|
|
1594
|
+
...(m.contextLength ? { context_length: m.contextLength } : {}),
|
|
1595
|
+
...(m.streamingSupported !== undefined
|
|
1596
|
+
? { streaming_supported: m.streamingSupported }
|
|
1597
|
+
: {}),
|
|
1598
|
+
...(m.deprecated !== undefined ? { deprecated: m.deprecated } : {}),
|
|
1599
|
+
}));
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1603
|
+
res.end(JSON.stringify({ object: 'list', data }));
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (req.method === 'GET' &&
|
|
1607
|
+
(urlPath === '/v1/embedding-models' || urlPath === '/embedding-models')) {
|
|
1608
|
+
let data = [];
|
|
1609
|
+
if (modelProvider?.getEmbeddingModels) {
|
|
1610
|
+
const result = await modelProvider.getEmbeddingModels();
|
|
1611
|
+
if (result.ok) {
|
|
1612
|
+
data = result.value.map((m) => ({
|
|
1613
|
+
id: m.id,
|
|
1614
|
+
object: 'model',
|
|
1615
|
+
owned_by: m.owned_by ?? 'unknown',
|
|
1616
|
+
...(m.displayName ? { display_name: m.displayName } : {}),
|
|
1617
|
+
...(m.provider ? { provider: m.provider } : {}),
|
|
1618
|
+
...(m.capabilities ? { capabilities: m.capabilities } : {}),
|
|
1619
|
+
...(m.contextLength ? { context_length: m.contextLength } : {}),
|
|
1620
|
+
...(m.streamingSupported !== undefined
|
|
1621
|
+
? { streaming_supported: m.streamingSupported }
|
|
1622
|
+
: {}),
|
|
1623
|
+
...(m.deprecated !== undefined ? { deprecated: m.deprecated } : {}),
|
|
1624
|
+
}));
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1628
|
+
res.end(JSON.stringify({ object: 'list', data }));
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (req.method === 'GET' && urlPath === '/v1/usage') {
|
|
1632
|
+
const lifecycle = this._lifecycle;
|
|
1633
|
+
if (!lifecycle) {
|
|
1634
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1635
|
+
res.end(jsonError('Session lifecycle not initialized', 'server_error'));
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1639
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1640
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1641
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1642
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1643
|
+
}
|
|
1644
|
+
const sessionId = resolved.identity.sessionId;
|
|
1645
|
+
const graph = await lifecycle.acquire(sessionId);
|
|
1646
|
+
try {
|
|
1647
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1648
|
+
res.end(JSON.stringify(graph.logger.getSummary()));
|
|
1649
|
+
}
|
|
1650
|
+
finally {
|
|
1651
|
+
lifecycle.release(sessionId, graph);
|
|
1652
|
+
}
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
// GET /v1/sessions — list sessions for the current identity
|
|
1656
|
+
if (req.method === 'GET' && urlPath === '/v1/sessions') {
|
|
1657
|
+
const lifecycle = this._lifecycle;
|
|
1658
|
+
if (!lifecycle) {
|
|
1659
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1660
|
+
res.end(jsonError('Session lifecycle not initialized', 'server_error'));
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1664
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1665
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1666
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1667
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1668
|
+
}
|
|
1669
|
+
const identity = resolved.identity.sessionId;
|
|
1670
|
+
const body = await handleListSessions(this._sessionMetaStore, identity);
|
|
1671
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1672
|
+
res.end(JSON.stringify(body));
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
// POST /v1/sessions/:id/resume — resume a session
|
|
1676
|
+
{
|
|
1677
|
+
const resumeMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)\/resume$/);
|
|
1678
|
+
if (req.method === 'POST' && resumeMatch) {
|
|
1679
|
+
const sessionId = resumeMatch[1];
|
|
1680
|
+
const lifecycle = this._lifecycle;
|
|
1681
|
+
if (!lifecycle) {
|
|
1682
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1683
|
+
res.end(jsonError('Session lifecycle not initialized', 'server_error'));
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1687
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1688
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1689
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1690
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1691
|
+
}
|
|
1692
|
+
const identity = resolved.identity.sessionId;
|
|
1693
|
+
const body = await handleResumeSession(this._sessionMetaStore, identity, sessionId);
|
|
1694
|
+
const status = body.ok ? 200 : 404;
|
|
1695
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1696
|
+
res.end(JSON.stringify(body));
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
// DELETE /v1/sessions/:id — delete a session
|
|
1701
|
+
{
|
|
1702
|
+
const deleteMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
1703
|
+
if (req.method === 'DELETE' && deleteMatch) {
|
|
1704
|
+
const sessionId = deleteMatch[1];
|
|
1705
|
+
const lifecycle = this._lifecycle;
|
|
1706
|
+
if (!lifecycle) {
|
|
1707
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1708
|
+
res.end(jsonError('Session lifecycle not initialized', 'server_error'));
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1712
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1713
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1714
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1715
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1716
|
+
}
|
|
1717
|
+
const identity = resolved.identity.sessionId;
|
|
1718
|
+
const evictFn = async (sid) => {
|
|
1719
|
+
// (a) Evict/dispose this session's graph from the registry.
|
|
1720
|
+
await lifecycle.registry.evictOne(sid);
|
|
1721
|
+
// (b) Evict the session's knowledge from the shared backend. This
|
|
1722
|
+
// clears the long-lived in-memory backend AND removes the JSONL files
|
|
1723
|
+
// (JsonlKnowledgeBackend.deleteSession), so a same-id re-entry never
|
|
1724
|
+
// rehydrates stale entries — matching the README "evicts its
|
|
1725
|
+
// knowledge-RAG entries" contract.
|
|
1726
|
+
await this._stepperKnowledgeBackend?.deleteSession(sid);
|
|
1727
|
+
};
|
|
1728
|
+
const body = await handleDeleteSession(this._sessionMetaStore, identity, sessionId, evictFn);
|
|
1729
|
+
const status = body.ok ? 200 : 404;
|
|
1730
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1731
|
+
res.end(JSON.stringify(body));
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// /v1/config or /config
|
|
1736
|
+
if (urlPath === '/v1/config' || urlPath === '/config') {
|
|
1737
|
+
if (req.method === 'GET') {
|
|
1738
|
+
const models = smartAgent.getActiveConfig();
|
|
1739
|
+
const agent = smartAgent.getAgentConfig();
|
|
1740
|
+
const body = { models, agent };
|
|
1741
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1742
|
+
res.end(JSON.stringify(body));
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
if (req.method === 'PUT') {
|
|
1746
|
+
await this._handleConfigUpdate(req, res, smartAgent);
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
// 405 for other methods
|
|
1750
|
+
res.setHeader('Allow', 'GET, PUT, OPTIONS');
|
|
1751
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
1752
|
+
res.end(jsonError(`Method ${req.method} not allowed on ${urlPath}`, 'invalid_request_error'));
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (req.method === 'GET' &&
|
|
1756
|
+
(urlPath === '/health' || urlPath === '/v1/health')) {
|
|
1757
|
+
const status = await healthChecker.check();
|
|
1758
|
+
const httpCode = status.status === 'unhealthy' ? 503 : 200;
|
|
1759
|
+
res.writeHead(httpCode, { 'Content-Type': 'application/json' });
|
|
1760
|
+
res.end(JSON.stringify(status));
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
// POST /v1/messages or /messages → Anthropic adapter
|
|
1764
|
+
if (req.method === 'POST' &&
|
|
1765
|
+
(urlPath === '/v1/messages' || urlPath === '/messages')) {
|
|
1766
|
+
const anthropicAdapter = adapterMap?.get('anthropic');
|
|
1767
|
+
if (!anthropicAdapter) {
|
|
1768
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1769
|
+
res.end(jsonError('Anthropic adapter not registered', 'not_found'));
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
await this._withSession(req, res, async (graph, sessionId, traceId) => {
|
|
1773
|
+
await this._handleAdapterRequest(req, res, graph.agent ?? smartAgent, anthropicAdapter, { sessionId, traceId, graph });
|
|
1774
|
+
});
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (req.method === 'POST' &&
|
|
1778
|
+
(urlPath === '/v1/chat/completions' || urlPath === '/chat/completions')) {
|
|
1779
|
+
await this._withSession(req, res, async (graph, sessionId, traceId) => {
|
|
1780
|
+
await this._handleChat(req, res, requestLogger, graph.agent ?? smartAgent, chat, streamChat, log, modelProvider, { sessionId, traceId, graph });
|
|
1781
|
+
});
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1785
|
+
res.end(jsonError(`Cannot ${req.method} ${urlPath}`, 'invalid_request_error'));
|
|
1786
|
+
}
|
|
1787
|
+
async _handleAdapterRequest(req, res, agent, adapter, session) {
|
|
1788
|
+
const raw = await readBody(req);
|
|
1789
|
+
let body;
|
|
1790
|
+
try {
|
|
1791
|
+
body = JSON.parse(raw);
|
|
1792
|
+
}
|
|
1793
|
+
catch {
|
|
1794
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1795
|
+
res.end(jsonError('Invalid JSON', 'invalid_request_error'));
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
let normalized;
|
|
1799
|
+
try {
|
|
1800
|
+
normalized = adapter.normalizeRequest(body);
|
|
1801
|
+
}
|
|
1802
|
+
catch (err) {
|
|
1803
|
+
if (err instanceof AdapterValidationError) {
|
|
1804
|
+
res.writeHead(err.statusCode, { 'Content-Type': 'application/json' });
|
|
1805
|
+
res.end(jsonError(err.message, 'invalid_request_error'));
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
throw err;
|
|
1809
|
+
}
|
|
1810
|
+
const augmentedOptions = session
|
|
1811
|
+
? {
|
|
1812
|
+
...normalized.options,
|
|
1813
|
+
sessionId: session.sessionId,
|
|
1814
|
+
trace: { traceId: session.traceId },
|
|
1815
|
+
toolAvailability: session.graph.toolAvailability,
|
|
1816
|
+
pendingToolResults: session.graph.pendingToolResults,
|
|
1817
|
+
}
|
|
1818
|
+
: normalized.options;
|
|
1819
|
+
if (normalized.stream) {
|
|
1820
|
+
res.writeHead(200, {
|
|
1821
|
+
'Content-Type': 'text/event-stream',
|
|
1822
|
+
'Cache-Control': 'no-cache',
|
|
1823
|
+
Connection: 'keep-alive',
|
|
1824
|
+
});
|
|
1825
|
+
for await (const event of adapter.transformStream(agent.streamProcess(normalized.messages, augmentedOptions), normalized.context)) {
|
|
1826
|
+
const eventLine = event.event ? `event: ${event.event}\n` : '';
|
|
1827
|
+
res.write(`${eventLine}data: ${event.data}\n\n`);
|
|
1828
|
+
}
|
|
1829
|
+
res.end();
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
// Non-streaming
|
|
1833
|
+
const result = await agent.process(normalized.messages, augmentedOptions);
|
|
1834
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1835
|
+
if (!result.ok) {
|
|
1836
|
+
res.writeHead(500);
|
|
1837
|
+
res.end(JSON.stringify(adapter.formatError?.(result.error, normalized.context) ?? {
|
|
1838
|
+
error: {
|
|
1839
|
+
message: result.error.message,
|
|
1840
|
+
type: result.error.code,
|
|
1841
|
+
},
|
|
1842
|
+
}));
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
res.writeHead(200);
|
|
1846
|
+
res.end(JSON.stringify(adapter.formatResult(result.value, normalized.context)));
|
|
1847
|
+
}
|
|
1848
|
+
async _handleChat(req, res, _requestLogger, smartAgent, _chat, _streamChat, log, modelProvider, session) {
|
|
1849
|
+
const rawBody = await readBody(req);
|
|
1850
|
+
let parsed;
|
|
1851
|
+
try {
|
|
1852
|
+
parsed = JSON.parse(rawBody);
|
|
1853
|
+
}
|
|
1854
|
+
catch {
|
|
1855
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1856
|
+
res.end(jsonError('Invalid JSON body', 'invalid_request_error'));
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (typeof parsed !== 'object' ||
|
|
1860
|
+
parsed === null ||
|
|
1861
|
+
!Array.isArray(parsed.messages)) {
|
|
1862
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1863
|
+
res.end(jsonError('messages must be a non-empty array', 'invalid_request_error'));
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
const body = parsed;
|
|
1867
|
+
const extractText = (c) => {
|
|
1868
|
+
if (c === null || c === undefined)
|
|
1869
|
+
return '';
|
|
1870
|
+
if (typeof c === 'string')
|
|
1871
|
+
return c;
|
|
1872
|
+
if (!Array.isArray(c))
|
|
1873
|
+
return '';
|
|
1874
|
+
return c
|
|
1875
|
+
.filter((b) => typeof b === 'object' &&
|
|
1876
|
+
b !== null &&
|
|
1877
|
+
b.type === 'text' &&
|
|
1878
|
+
typeof b.text === 'string')
|
|
1879
|
+
.map((b) => b.text)
|
|
1880
|
+
.join('\n');
|
|
1881
|
+
};
|
|
1882
|
+
const userMessages = body.messages.filter((m) => m.role === 'user');
|
|
1883
|
+
if (userMessages.length === 0) {
|
|
1884
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1885
|
+
res.end(jsonError('at least one message with role "user" is required', 'invalid_request_error'));
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
// Prefer the session injected by `_withSession` (cookie identity); fall
|
|
1889
|
+
// back to the legacy x-session-id header / 'default' bucket only when no
|
|
1890
|
+
// session was wired (defensive — production routes always inject one).
|
|
1891
|
+
const traceId = session?.traceId ?? randomUUID();
|
|
1892
|
+
const sessionId = session?.sessionId ??
|
|
1893
|
+
req.headers['x-session-id'] ??
|
|
1894
|
+
'default';
|
|
1895
|
+
const sessionLogger = new SessionLogger(this.cfg.logDir || null, sessionId, traceId);
|
|
1896
|
+
const toolsValidationMode = this.cfg.agent?.externalToolsValidationMode ?? 'permissive';
|
|
1897
|
+
const externalToolsValidation = normalizeAndValidateExternalTools(body.tools);
|
|
1898
|
+
const externalTools = externalToolsValidation.tools;
|
|
1899
|
+
if (externalToolsValidation.errors.length > 0) {
|
|
1900
|
+
log({
|
|
1901
|
+
event: 'invalid_external_tools_detected',
|
|
1902
|
+
traceId,
|
|
1903
|
+
sessionId,
|
|
1904
|
+
mode: toolsValidationMode,
|
|
1905
|
+
count: externalToolsValidation.errors.length,
|
|
1906
|
+
errors: externalToolsValidation.errors,
|
|
1907
|
+
});
|
|
1908
|
+
sessionLogger.logStep('invalid_external_tools_detected', {
|
|
1909
|
+
mode: toolsValidationMode,
|
|
1910
|
+
count: externalToolsValidation.errors.length,
|
|
1911
|
+
errors: externalToolsValidation.errors,
|
|
1912
|
+
});
|
|
1913
|
+
if (toolsValidationMode === 'strict') {
|
|
1914
|
+
const firstError = externalToolsValidation.errors[0];
|
|
1915
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1916
|
+
res.end(jsonValidationError(firstError.message, firstError.code, firstError.param));
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const t0 = Date.now();
|
|
1921
|
+
log({ event: 'request_start', stream: body.stream ?? false, traceId });
|
|
1922
|
+
const opts = {
|
|
1923
|
+
stream: body.stream,
|
|
1924
|
+
externalTools,
|
|
1925
|
+
sessionId,
|
|
1926
|
+
trace: { traceId },
|
|
1927
|
+
sessionLogger,
|
|
1928
|
+
model: body.model,
|
|
1929
|
+
...(session
|
|
1930
|
+
? {
|
|
1931
|
+
toolAvailability: session.graph.toolAvailability,
|
|
1932
|
+
pendingToolResults: session.graph.pendingToolResults,
|
|
1933
|
+
}
|
|
1934
|
+
: {}),
|
|
1935
|
+
...(body.temperature !== undefined
|
|
1936
|
+
? { temperature: body.temperature }
|
|
1937
|
+
: {}),
|
|
1938
|
+
...(body.max_tokens !== undefined ? { maxTokens: body.max_tokens } : {}),
|
|
1939
|
+
...(body.top_p !== undefined ? { topP: body.top_p } : {}),
|
|
1940
|
+
...(body.stop !== undefined
|
|
1941
|
+
? { stop: Array.isArray(body.stop) ? body.stop : [body.stop] }
|
|
1942
|
+
: {}),
|
|
1943
|
+
};
|
|
1944
|
+
const responseModel = body.model ?? modelProvider?.getModel() ?? 'smart-agent';
|
|
1945
|
+
const normalizedMessages = body.messages
|
|
1946
|
+
.map((m) => {
|
|
1947
|
+
const role = m.role;
|
|
1948
|
+
const normalizedMessage = {
|
|
1949
|
+
role,
|
|
1950
|
+
content: extractText(m.content),
|
|
1951
|
+
};
|
|
1952
|
+
if (role === 'tool') {
|
|
1953
|
+
if (typeof m.tool_call_id === 'string' && m.tool_call_id.trim()) {
|
|
1954
|
+
normalizedMessage.tool_call_id = m.tool_call_id;
|
|
1955
|
+
}
|
|
1956
|
+
else {
|
|
1957
|
+
sessionLogger.logStep('drop_orphan_tool_message', {
|
|
1958
|
+
reason: 'missing_tool_call_id',
|
|
1959
|
+
});
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
if (role === 'assistant' && Array.isArray(m.tool_calls)) {
|
|
1964
|
+
const toolCalls = m.tool_calls
|
|
1965
|
+
.filter((tc) => typeof tc === 'object' &&
|
|
1966
|
+
tc !== null &&
|
|
1967
|
+
typeof tc.id === 'string' &&
|
|
1968
|
+
tc.type === 'function' &&
|
|
1969
|
+
typeof tc.function
|
|
1970
|
+
?.name === 'string' &&
|
|
1971
|
+
typeof tc.function
|
|
1972
|
+
?.arguments === 'string')
|
|
1973
|
+
.map((tc) => ({
|
|
1974
|
+
id: tc.id,
|
|
1975
|
+
type: 'function',
|
|
1976
|
+
function: {
|
|
1977
|
+
name: tc.function.name,
|
|
1978
|
+
arguments: tc.function.arguments,
|
|
1979
|
+
},
|
|
1980
|
+
}));
|
|
1981
|
+
if (toolCalls.length > 0) {
|
|
1982
|
+
normalizedMessage.tool_calls = toolCalls;
|
|
1983
|
+
if (!normalizedMessage.content)
|
|
1984
|
+
normalizedMessage.content = null;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return normalizedMessage;
|
|
1988
|
+
})
|
|
1989
|
+
.filter((m) => m !== null);
|
|
1990
|
+
const invalidToolsHeader = externalToolsValidation.errors.length > 0
|
|
1991
|
+
? {
|
|
1992
|
+
'x-smartagent-invalid-tools': String(externalToolsValidation.errors.length),
|
|
1993
|
+
}
|
|
1994
|
+
: {};
|
|
1995
|
+
if (body.stream) {
|
|
1996
|
+
res.writeHead(200, {
|
|
1997
|
+
'Content-Type': 'text/event-stream',
|
|
1998
|
+
'Cache-Control': 'no-cache',
|
|
1999
|
+
Connection: 'keep-alive',
|
|
2000
|
+
...invalidToolsHeader,
|
|
2001
|
+
});
|
|
2002
|
+
const id = `chatcmpl-${randomUUID()}`;
|
|
2003
|
+
const created = Math.floor(Date.now() / 1000);
|
|
2004
|
+
const stream = smartAgent.streamProcess(normalizedMessages, opts);
|
|
2005
|
+
let firstChunk = true;
|
|
2006
|
+
let finishReasonSent = false;
|
|
2007
|
+
let lastUsage = null;
|
|
2008
|
+
for await (const chunk of stream) {
|
|
2009
|
+
if (!chunk.ok) {
|
|
2010
|
+
const errorChunk = {
|
|
2011
|
+
id,
|
|
2012
|
+
object: 'chat.completion.chunk',
|
|
2013
|
+
created,
|
|
2014
|
+
model: responseModel,
|
|
2015
|
+
choices: [
|
|
2016
|
+
{
|
|
2017
|
+
index: 0,
|
|
2018
|
+
delta: { content: `[Error] ${chunk.error.message}` },
|
|
2019
|
+
finish_reason: 'stop',
|
|
2020
|
+
},
|
|
2021
|
+
],
|
|
2022
|
+
};
|
|
2023
|
+
res.write(`data: ${JSON.stringify(errorChunk)}\n\n`);
|
|
2024
|
+
finishReasonSent = true;
|
|
2025
|
+
break;
|
|
2026
|
+
}
|
|
2027
|
+
// SSE heartbeat comment — keeps connection alive, ignored by clients
|
|
2028
|
+
if (chunk.value.heartbeat) {
|
|
2029
|
+
const hb = chunk.value.heartbeat;
|
|
2030
|
+
res.write(`: heartbeat tool=${hb.tool} elapsed=${hb.elapsed}ms\n\n`);
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
// SSE timing breakdown comment — sent with the final chunk
|
|
2034
|
+
if (chunk.value.timing) {
|
|
2035
|
+
const parts = chunk.value.timing.map((t) => `${t.phase}=${t.duration}ms`);
|
|
2036
|
+
res.write(`: timing ${parts.join(' ')}\n\n`);
|
|
2037
|
+
}
|
|
2038
|
+
if (chunk.value.usage) {
|
|
2039
|
+
lastUsage = {
|
|
2040
|
+
prompt_tokens: chunk.value.usage.promptTokens,
|
|
2041
|
+
completion_tokens: chunk.value.usage.completionTokens,
|
|
2042
|
+
total_tokens: chunk.value.usage.totalTokens,
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
const baseResponse = {
|
|
2046
|
+
id,
|
|
2047
|
+
object: 'chat.completion.chunk',
|
|
2048
|
+
created,
|
|
2049
|
+
model: responseModel,
|
|
2050
|
+
usage: null,
|
|
2051
|
+
};
|
|
2052
|
+
if (firstChunk) {
|
|
2053
|
+
res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: { role: 'assistant', content: chunk.value.content || '' }, finish_reason: null }] })}\n\n`);
|
|
2054
|
+
firstChunk = false;
|
|
2055
|
+
if (!chunk.value.finishReason && !chunk.value.toolCalls)
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
2058
|
+
if (chunk.value.content || chunk.value.toolCalls) {
|
|
2059
|
+
const delta = {};
|
|
2060
|
+
if (chunk.value.content)
|
|
2061
|
+
delta.content = chunk.value.content;
|
|
2062
|
+
if (chunk.value.toolCalls) {
|
|
2063
|
+
delta.tool_calls = chunk.value.toolCalls.map((call, index) => {
|
|
2064
|
+
const tc = toToolCallDelta(call, index);
|
|
2065
|
+
return {
|
|
2066
|
+
index: tc.index,
|
|
2067
|
+
id: tc.id,
|
|
2068
|
+
type: 'function',
|
|
2069
|
+
function: {
|
|
2070
|
+
name: tc.name,
|
|
2071
|
+
arguments: tc.arguments || '',
|
|
2072
|
+
},
|
|
2073
|
+
};
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`);
|
|
2077
|
+
}
|
|
2078
|
+
if (chunk.value.finishReason) {
|
|
2079
|
+
res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: {}, finish_reason: mapStopReason(chunk.value.finishReason) }] })}\n\n`);
|
|
2080
|
+
finishReasonSent = true;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
if (!finishReasonSent) {
|
|
2084
|
+
const baseResponse = {
|
|
2085
|
+
id,
|
|
2086
|
+
object: 'chat.completion.chunk',
|
|
2087
|
+
created,
|
|
2088
|
+
model: responseModel,
|
|
2089
|
+
usage: null,
|
|
2090
|
+
};
|
|
2091
|
+
res.write(`data: ${JSON.stringify({ ...baseResponse, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`);
|
|
2092
|
+
}
|
|
2093
|
+
if ((this.cfg.reportUsage !== false ||
|
|
2094
|
+
body.stream_options?.include_usage) &&
|
|
2095
|
+
lastUsage) {
|
|
2096
|
+
res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model: responseModel, choices: [], usage: lastUsage })}\n\n`);
|
|
2097
|
+
}
|
|
2098
|
+
res.write('data: [DONE]\n\n');
|
|
2099
|
+
res.end();
|
|
2100
|
+
log({
|
|
2101
|
+
event: 'request_done',
|
|
2102
|
+
ok: true,
|
|
2103
|
+
stream: true,
|
|
2104
|
+
finishReason: finishReasonSent ? 'sent' : 'fallback_stop',
|
|
2105
|
+
durationMs: Date.now() - t0,
|
|
2106
|
+
});
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
const result = await smartAgent.process(normalizedMessages, opts);
|
|
2110
|
+
log({ event: 'request_done', ok: result.ok, durationMs: Date.now() - t0 });
|
|
2111
|
+
const finalContent = result.ok
|
|
2112
|
+
? result.value.content ||
|
|
2113
|
+
(result.value.toolCalls ? null : '(no response)')
|
|
2114
|
+
: `Error: ${result.error.message}`;
|
|
2115
|
+
const finalFinishReason = result.ok
|
|
2116
|
+
? mapStopReason(result.value.stopReason)
|
|
2117
|
+
: 'stop';
|
|
2118
|
+
let finalUsage = null;
|
|
2119
|
+
if (result.ok && result.value.usage) {
|
|
2120
|
+
finalUsage = {
|
|
2121
|
+
prompt_tokens: result.value.usage.promptTokens,
|
|
2122
|
+
completion_tokens: result.value.usage.completionTokens,
|
|
2123
|
+
total_tokens: result.value.usage.totalTokens,
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
const message = {
|
|
2127
|
+
role: 'assistant',
|
|
2128
|
+
content: finalContent,
|
|
2129
|
+
};
|
|
2130
|
+
if (result.ok && result.value.toolCalls) {
|
|
2131
|
+
message.tool_calls = result.value.toolCalls;
|
|
2132
|
+
}
|
|
2133
|
+
res.writeHead(200, {
|
|
2134
|
+
'Content-Type': 'application/json',
|
|
2135
|
+
...invalidToolsHeader,
|
|
2136
|
+
});
|
|
2137
|
+
res.end(JSON.stringify({
|
|
2138
|
+
id: `chatcmpl-${randomUUID()}`,
|
|
2139
|
+
object: 'chat.completion',
|
|
2140
|
+
created: Math.floor(Date.now() / 1000),
|
|
2141
|
+
model: responseModel,
|
|
2142
|
+
choices: [
|
|
2143
|
+
{
|
|
2144
|
+
index: 0,
|
|
2145
|
+
message,
|
|
2146
|
+
finish_reason: finalFinishReason,
|
|
2147
|
+
},
|
|
2148
|
+
],
|
|
2149
|
+
usage: finalUsage || {
|
|
2150
|
+
prompt_tokens: 0,
|
|
2151
|
+
completion_tokens: 0,
|
|
2152
|
+
total_tokens: 0,
|
|
2153
|
+
},
|
|
2154
|
+
}));
|
|
2155
|
+
}
|
|
2156
|
+
/** Whitelisted agent config fields allowed via PUT /v1/config. */
|
|
2157
|
+
static AGENT_CONFIG_FIELDS = new Set([
|
|
2158
|
+
'maxIterations',
|
|
2159
|
+
'maxToolCalls',
|
|
2160
|
+
'ragQueryK',
|
|
2161
|
+
'toolUnavailableTtlMs',
|
|
2162
|
+
'showReasoning',
|
|
2163
|
+
'historyAutoSummarizeLimit',
|
|
2164
|
+
'classificationEnabled',
|
|
2165
|
+
]);
|
|
2166
|
+
async _handleConfigUpdate(req, res, smartAgent) {
|
|
2167
|
+
const raw = await readBody(req);
|
|
2168
|
+
let parsed;
|
|
2169
|
+
try {
|
|
2170
|
+
parsed = JSON.parse(raw);
|
|
2171
|
+
}
|
|
2172
|
+
catch {
|
|
2173
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2174
|
+
res.end(jsonError('Invalid JSON body', 'invalid_request_error'));
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
if (typeof parsed !== 'object' ||
|
|
2178
|
+
parsed === null ||
|
|
2179
|
+
Array.isArray(parsed)) {
|
|
2180
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2181
|
+
res.end(jsonError('Request body must be a JSON object', 'invalid_request_error'));
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
const body = parsed;
|
|
2185
|
+
// --- Validate agent fields against whitelist ---
|
|
2186
|
+
if (body.agent !== undefined) {
|
|
2187
|
+
if (typeof body.agent !== 'object' ||
|
|
2188
|
+
body.agent === null ||
|
|
2189
|
+
Array.isArray(body.agent)) {
|
|
2190
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2191
|
+
res.end(jsonError('"agent" must be a JSON object', 'invalid_request_error'));
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
const agentFields = body.agent;
|
|
2195
|
+
const unsupported = Object.keys(agentFields).filter((k) => !SmartServer.AGENT_CONFIG_FIELDS.has(k));
|
|
2196
|
+
if (unsupported.length > 0) {
|
|
2197
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2198
|
+
res.end(jsonError(`Unsupported agent config fields: ${unsupported.join(', ')}`, 'invalid_request_error'));
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
// --- Validate and resolve models (atomic: resolve ALL before mutating) ---
|
|
2203
|
+
let resolvedModels;
|
|
2204
|
+
if (body.models !== undefined) {
|
|
2205
|
+
if (typeof body.models !== 'object' ||
|
|
2206
|
+
body.models === null ||
|
|
2207
|
+
Array.isArray(body.models)) {
|
|
2208
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2209
|
+
res.end(jsonError('"models" must be a JSON object', 'invalid_request_error'));
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
if (!this.cfg.modelResolver) {
|
|
2213
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2214
|
+
res.end(jsonError('model resolver not configured', 'invalid_request_error'));
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
const modelFields = body.models;
|
|
2218
|
+
const validKeys = new Set([
|
|
2219
|
+
'mainModel',
|
|
2220
|
+
'classifierModel',
|
|
2221
|
+
'helperModel',
|
|
2222
|
+
]);
|
|
2223
|
+
const unknownKeys = Object.keys(modelFields).filter((k) => !validKeys.has(k));
|
|
2224
|
+
if (unknownKeys.length > 0) {
|
|
2225
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2226
|
+
res.end(jsonError(`Unknown model fields: ${unknownKeys.join(', ')}`, 'invalid_request_error'));
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
try {
|
|
2230
|
+
const resolver = this.cfg.modelResolver;
|
|
2231
|
+
const [mainLlm, classifierLlm, helperLlm] = await Promise.all([
|
|
2232
|
+
modelFields.mainModel
|
|
2233
|
+
? resolver.resolve(String(modelFields.mainModel), 'main')
|
|
2234
|
+
: undefined,
|
|
2235
|
+
modelFields.classifierModel
|
|
2236
|
+
? resolver.resolve(String(modelFields.classifierModel), 'classifier')
|
|
2237
|
+
: undefined,
|
|
2238
|
+
modelFields.helperModel
|
|
2239
|
+
? resolver.resolve(String(modelFields.helperModel), 'helper')
|
|
2240
|
+
: undefined,
|
|
2241
|
+
]);
|
|
2242
|
+
resolvedModels = {};
|
|
2243
|
+
if (mainLlm)
|
|
2244
|
+
resolvedModels.mainLlm = mainLlm;
|
|
2245
|
+
if (classifierLlm)
|
|
2246
|
+
resolvedModels.classifierLlm = classifierLlm;
|
|
2247
|
+
if (helperLlm)
|
|
2248
|
+
resolvedModels.helperLlm = helperLlm;
|
|
2249
|
+
}
|
|
2250
|
+
catch (err) {
|
|
2251
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2252
|
+
res.end(jsonError(String(err), 'server_error'));
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
// --- All validation passed — apply mutations ---
|
|
2257
|
+
if (resolvedModels) {
|
|
2258
|
+
smartAgent.reconfigure(resolvedModels);
|
|
2259
|
+
// Mirror onto the hoisted globals consumed by `buildSessionAgent` so
|
|
2260
|
+
// freshly-built session graphs pick up the new LLMs by reference
|
|
2261
|
+
// (otherwise `this._mainLlm` etc. would keep pointing at the originals
|
|
2262
|
+
// captured during `start()`).
|
|
2263
|
+
if (resolvedModels.mainLlm)
|
|
2264
|
+
this._mainLlm = resolvedModels.mainLlm;
|
|
2265
|
+
if (resolvedModels.classifierLlm)
|
|
2266
|
+
this._classifierLlm = resolvedModels.classifierLlm;
|
|
2267
|
+
if (resolvedModels.helperLlm)
|
|
2268
|
+
this._helperLlm = resolvedModels.helperLlm;
|
|
2269
|
+
}
|
|
2270
|
+
if (body.agent) {
|
|
2271
|
+
const patch = body.agent;
|
|
2272
|
+
smartAgent.applyConfigUpdate(patch);
|
|
2273
|
+
// Mirror onto `this.cfg.agent` so freshly-built session graphs (which
|
|
2274
|
+
// read `this.cfg.agent` in `buildSessionAgent`) observe the update.
|
|
2275
|
+
// Deep-merge to preserve untouched startup fields; replacing the whole
|
|
2276
|
+
// `agent` block would drop YAML defaults the validator already applied.
|
|
2277
|
+
const merged = {
|
|
2278
|
+
...(this.cfg.agent ?? {}),
|
|
2279
|
+
...patch,
|
|
2280
|
+
};
|
|
2281
|
+
this.cfg.agent = merged;
|
|
2282
|
+
}
|
|
2283
|
+
// Invalidate per-session SmartAgents + the worker-LLM cache so the next
|
|
2284
|
+
// request mints a session graph that observes the just-applied config.
|
|
2285
|
+
// Without this, chat routes dispatch to `graph.agent` (the per-session
|
|
2286
|
+
// SmartAgent) which was built with the OLD config, and the PUT is a
|
|
2287
|
+
// no-op from the consumer's perspective. Failures are non-fatal so the
|
|
2288
|
+
// 200 response isn't blocked by a dispose hiccup.
|
|
2289
|
+
if (resolvedModels || body.agent) {
|
|
2290
|
+
// Fix #21: drain per-worker SmartAgentHandle.close() BEFORE clearing the
|
|
2291
|
+
// cache so MCP clients owned by the discarded handles disconnect.
|
|
2292
|
+
await drainWorkerCache(this._workerLlmCache);
|
|
2293
|
+
try {
|
|
2294
|
+
await this._lifecycle?.invalidateAll();
|
|
2295
|
+
}
|
|
2296
|
+
catch {
|
|
2297
|
+
// Swallow: cleanup errors must not turn a successful config update
|
|
2298
|
+
// into a 500. The next request will still get a fresh build because
|
|
2299
|
+
// `_workerLlmCache` is already cleared and dispose is idempotent.
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
// --- Return updated config ---
|
|
2303
|
+
const models = smartAgent.getActiveConfig();
|
|
2304
|
+
const agent = smartAgent.getAgentConfig();
|
|
2305
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2306
|
+
res.end(JSON.stringify({ models, agent }));
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
//# sourceMappingURL=smart-server.js.map
|