@mcp-abap-adt/llm-agent-server 17.0.0 → 18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/smart-agent/build-stepper-root.d.ts +97 -0
- package/dist/smart-agent/build-stepper-root.d.ts.map +1 -0
- package/dist/smart-agent/build-stepper-root.js +242 -0
- package/dist/smart-agent/build-stepper-root.js.map +1 -0
- package/dist/smart-agent/config.d.ts +119 -1
- package/dist/smart-agent/config.d.ts.map +1 -1
- package/dist/smart-agent/config.js +219 -24
- package/dist/smart-agent/config.js.map +1 -1
- 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/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 +99 -2
- package/dist/smart-agent/smart-server.d.ts.map +1 -1
- package/dist/smart-agent/smart-server.js +546 -8
- package/dist/smart-agent/smart-server.js.map +1 -1
- 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 +214 -0
- package/dist/smart-agent/stepper-coordinator-handler.js.map +1 -0
- package/package.json +26 -26
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import http from 'node:http';
|
|
6
6
|
import { AdapterValidationError, normalizeAndValidateExternalTools, QueryEmbedding, toToolCallDelta, } from '@mcp-abap-adt/llm-agent';
|
|
7
|
-
import { ClaudeSkillManager, CodexSkillManager, ConfigWatcher, DefaultSubAgentContextBuilder, FileSystemPluginLoader, FileSystemSkillManager, getDefaultPluginDirs, HealthChecker, makeLlm, SessionGraphFactory, SessionLogger, SessionRegistry, SmartAgentBuilder, SmartAgentSubAgent, SubAgentStateOracle, } from '@mcp-abap-adt/llm-agent-libs';
|
|
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';
|
|
8
9
|
import { makeRag } from '@mcp-abap-adt/llm-agent-rag';
|
|
9
10
|
import { PACKAGE_VERSION } from '../generated/version.js';
|
|
10
11
|
import { resolveAgentEmbedder, resolveToolsStoreEmbedder, } from './resolve-agent-embedder.js';
|
|
@@ -67,7 +68,11 @@ const CORS_HEADERS = {
|
|
|
67
68
|
// SmartServer
|
|
68
69
|
// ---------------------------------------------------------------------------
|
|
69
70
|
import { buildDagCoordinatorDeps } from './build-dag-coordinator-deps.js';
|
|
70
|
-
import {
|
|
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';
|
|
71
76
|
export { generateConfigTemplate, loadYamlConfig, resolveCoordinatorActivation, resolveCoordinatorDispatch, resolveCoordinatorPlanning, resolveEnvVars, resolveSmartServerConfig, resolveToolSelectionStrategy, YAML_TEMPLATE, } from './config.js';
|
|
72
77
|
/**
|
|
73
78
|
* Drain every cached worker's `close` (if any), then clear the cache map.
|
|
@@ -235,6 +240,213 @@ export function buildSessionLifecycle(opts) {
|
|
|
235
240
|
registry,
|
|
236
241
|
};
|
|
237
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
|
+
/** Deterministic cache key for tool args — stable regardless of key order. */
|
|
364
|
+
function stableArgsKey(args) {
|
|
365
|
+
if (args === null || typeof args !== 'object')
|
|
366
|
+
return JSON.stringify(args);
|
|
367
|
+
if (Array.isArray(args))
|
|
368
|
+
return JSON.stringify(args.map((v) => v));
|
|
369
|
+
const obj = args;
|
|
370
|
+
const sorted = {};
|
|
371
|
+
for (const k of Object.keys(obj).sort())
|
|
372
|
+
sorted[k] = obj[k];
|
|
373
|
+
return JSON.stringify(sorted);
|
|
374
|
+
}
|
|
375
|
+
export async function connectMcpClientsFromConfig(mcpCfg) {
|
|
376
|
+
if (!mcpCfg)
|
|
377
|
+
return [];
|
|
378
|
+
const list = Array.isArray(mcpCfg) ? mcpCfg : [mcpCfg];
|
|
379
|
+
const connected = [];
|
|
380
|
+
for (const cfg of list) {
|
|
381
|
+
let wrapper;
|
|
382
|
+
if (cfg.type === 'stdio') {
|
|
383
|
+
wrapper = new MCPClientWrapper({
|
|
384
|
+
transport: 'stdio',
|
|
385
|
+
command: cfg.command,
|
|
386
|
+
args: cfg.args ?? [],
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
wrapper = new MCPClientWrapper({
|
|
391
|
+
transport: 'auto',
|
|
392
|
+
url: cfg.url,
|
|
393
|
+
headers: cfg.headers,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
await wrapper.connect();
|
|
397
|
+
connected.push(new McpClientAdapter(wrapper));
|
|
398
|
+
}
|
|
399
|
+
return connected;
|
|
400
|
+
}
|
|
401
|
+
export function buildMcpBridge(clients) {
|
|
402
|
+
return async (name, args, _signal) => {
|
|
403
|
+
const safeArgs = args != null && typeof args === 'object' && !Array.isArray(args)
|
|
404
|
+
? args
|
|
405
|
+
: {};
|
|
406
|
+
for (const client of clients) {
|
|
407
|
+
const listed = await client.listTools();
|
|
408
|
+
if (!listed.ok)
|
|
409
|
+
continue;
|
|
410
|
+
const owns = listed.value.some((t) => t.name === name);
|
|
411
|
+
if (!owns)
|
|
412
|
+
continue;
|
|
413
|
+
const result = await client.callTool(name, safeArgs);
|
|
414
|
+
if (!result.ok) {
|
|
415
|
+
return result.error.message;
|
|
416
|
+
}
|
|
417
|
+
const { content } = result.value;
|
|
418
|
+
return typeof content === 'string' ? content : JSON.stringify(content);
|
|
419
|
+
}
|
|
420
|
+
return `Tool not found: ${name}`;
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// Raw-config mode routing gate (R6-F2)
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
/**
|
|
427
|
+
* Returns `true` ONLY when the raw coordinator config has an explicit `mode`
|
|
428
|
+
* string — the opt-in gate for the 18.0 Stepper runtime.
|
|
429
|
+
*
|
|
430
|
+
* IMPORTANT: Do NOT call `parseStepperCoordinatorConfig` to decide — that
|
|
431
|
+
* function defaults `mode` to `'planned-react'`, which would silently route
|
|
432
|
+
* every 17.0 legacy config onto the Stepper path. Gate on the RAW field
|
|
433
|
+
* presence only; parse is done INSIDE the Stepper branch after this check.
|
|
434
|
+
*/
|
|
435
|
+
export function usesStepper(coordCfg) {
|
|
436
|
+
if (!coordCfg)
|
|
437
|
+
return false;
|
|
438
|
+
// Explicit opt-in: `coordinator.mode` (preset alias) — a string in raw config.
|
|
439
|
+
if (typeof coordCfg.mode === 'string')
|
|
440
|
+
return true;
|
|
441
|
+
// OR an explicit `coordinator.flow` composition block (mode-less form). A flow
|
|
442
|
+
// with no mode is still a Stepper config; without this it would silently fall
|
|
443
|
+
// through to the plain smart pipeline.
|
|
444
|
+
if (typeof coordCfg.flow === 'object' && coordCfg.flow !== null)
|
|
445
|
+
return true;
|
|
446
|
+
// Legacy 17.0 configs with `coordinator.planner` but no `coordinator.mode`
|
|
447
|
+
// stay on the deprecated DagCoordinatorHandler (removed in 19.0).
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
238
450
|
export class SmartServer {
|
|
239
451
|
cfg;
|
|
240
452
|
noop = () => { };
|
|
@@ -258,6 +470,35 @@ export class SmartServer {
|
|
|
258
470
|
* session (review HIGH #1).
|
|
259
471
|
*/
|
|
260
472
|
_dagCoordinatorTemplate;
|
|
473
|
+
/**
|
|
474
|
+
* 18.0 Stepper coordinator handler — stateless, session context comes through
|
|
475
|
+
* `ctx.sessionId` at execute time. Built once in `start()` when
|
|
476
|
+
* `coordinator.mode` is present in the raw config; reused across sessions.
|
|
477
|
+
*/
|
|
478
|
+
_stepperCoordinatorHandler;
|
|
479
|
+
/**
|
|
480
|
+
* MCP clients connected for the Stepper path from the YAML `mcp:` config
|
|
481
|
+
* block. These are connected ONCE in `start()` (lazily resolved by
|
|
482
|
+
* `connectMcpClientsFromConfig`) and reused across every Stepper request.
|
|
483
|
+
*
|
|
484
|
+
* Populated only when `this.cfg.mcp` / `pipeline.mcp` is set AND no
|
|
485
|
+
* DI/plugin clients exist (DI precedence: `this.cfg.mcpClients` > plugin >
|
|
486
|
+
* yaml). Disposed via the server's `closeFns` on shutdown.
|
|
487
|
+
*/
|
|
488
|
+
_stepperMcpClients;
|
|
489
|
+
/**
|
|
490
|
+
* The ONE shared knowledge backend for the Stepper path (set during build).
|
|
491
|
+
* Held so DELETE /v1/sessions/:id can evict a session's entries from it —
|
|
492
|
+
* critical for the long-lived in-memory backend, which would otherwise retain
|
|
493
|
+
* knowledge after a delete and rehydrate it on a same-id re-entry.
|
|
494
|
+
*/
|
|
495
|
+
_stepperKnowledgeBackend;
|
|
496
|
+
/**
|
|
497
|
+
* Session meta-store for /v1/sessions endpoints (Task 17).
|
|
498
|
+
* Defaults to InMemorySessionMetaStore; a durable store can be injected via
|
|
499
|
+
* `cfg.sessionMetaStore` in a future extension.
|
|
500
|
+
*/
|
|
501
|
+
_sessionMetaStore = new InMemorySessionMetaStore();
|
|
261
502
|
constructor(config) {
|
|
262
503
|
this.cfg = config;
|
|
263
504
|
}
|
|
@@ -500,10 +741,189 @@ export class SmartServer {
|
|
|
500
741
|
// ---- Coordinator (autonomous plan-execute loop) ------------------------
|
|
501
742
|
const coordCfg = this.cfg.coordinatorYaml;
|
|
502
743
|
if (coordCfg) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
//
|
|
744
|
+
const rawCoordCfg = coordCfg;
|
|
745
|
+
if (usesStepper(rawCoordCfg)) {
|
|
746
|
+
// ── 18.0 Stepper path (opt-in via coordinator.mode) ──────────────────
|
|
747
|
+
// Parse only INSIDE this branch so parseStepperCoordinatorConfig's
|
|
748
|
+
// mode default ('planned-react') never fires for legacy configs (R6-F2).
|
|
749
|
+
const stepperCfg = parseStepperCoordinatorConfig(rawCoordCfg);
|
|
750
|
+
const logDir = this.cfg.logDir;
|
|
751
|
+
// KnowledgeRag factory: ONE backend instance shared across all requests.
|
|
752
|
+
// Both backends are keyed by sessionId internally, so a single instance
|
|
753
|
+
// gives correct per-session isolation AND persistence across same-cookie
|
|
754
|
+
// requests within the process. JsonlKnowledgeBackend (logDir set) adds
|
|
755
|
+
// durability across restarts; InMemoryKnowledgeBackend is the in-process
|
|
756
|
+
// default. (Previously a fresh InMemoryKnowledgeBackend was created per
|
|
757
|
+
// call, so subsequent same-session requests lost prior entries and were
|
|
758
|
+
// re-seeded — review fix.)
|
|
759
|
+
const knowledgeBackend = logDir
|
|
760
|
+
? new JsonlKnowledgeBackend(logDir)
|
|
761
|
+
: new InMemoryKnowledgeBackend();
|
|
762
|
+
// Held on the instance so DELETE /v1/sessions/:id can evict a session's
|
|
763
|
+
// knowledge from THIS backend (not just remove the JSONL file).
|
|
764
|
+
this._stepperKnowledgeBackend = knowledgeBackend;
|
|
765
|
+
const knowledgeRagFor = async (sessionId) => {
|
|
766
|
+
const kr = new KnowledgeRag(knowledgeBackend, sessionId);
|
|
767
|
+
// Seed deployment-supplied guidance into a BRAND-NEW session only
|
|
768
|
+
// (idempotent on resume). Config DATA — keeps the runtime MCP-agnostic.
|
|
769
|
+
await seedSessionKnowledge(kr, stepperCfg.knowledgeSeed, new Date().toISOString());
|
|
770
|
+
return kr;
|
|
771
|
+
};
|
|
772
|
+
// ToolsRag handle: real adapter over the server's tools RAG store + MCP
|
|
773
|
+
// catalog. Implements IToolsRagHandle for the Stepper runtime:
|
|
774
|
+
// query(text, k) — semantic search via toolsRag+embedder when available;
|
|
775
|
+
// catalog-order fallback when neither is present.
|
|
776
|
+
// lookup(name) — returns the tool schema from the MCP catalog by name
|
|
777
|
+
// (populated lazily on first query()).
|
|
778
|
+
//
|
|
779
|
+
// Catalog note: when DI/plugin clients are present they are already
|
|
780
|
+
// connected. When the config carries a YAML `mcp:` block instead, the
|
|
781
|
+
// builder connects those clients INSIDE builder.build() and never
|
|
782
|
+
// exposes them here. We therefore connect the YAML mcp config ONCE and
|
|
783
|
+
// cache the result in _stepperMcpClients so every subsequent Stepper
|
|
784
|
+
// request reuses the same live connections without reconnecting.
|
|
785
|
+
//
|
|
786
|
+
// DI precedence: this.cfg.mcpClients > plugin clients > yaml mcp config.
|
|
787
|
+
// NOTE: connection is NOT safe to invoke twice on the same wrapper, so
|
|
788
|
+
// the cache guard below is critical — do NOT move this inside a
|
|
789
|
+
// per-request factory.
|
|
790
|
+
if (!mcpClients && !this._stepperMcpClients) {
|
|
791
|
+
const yamlMcp = pipeline?.mcp ?? this.cfg.mcp;
|
|
792
|
+
this._stepperMcpClients = await connectMcpClientsFromConfig(yamlMcp);
|
|
793
|
+
}
|
|
794
|
+
const stepperMcpClients = mcpClients ?? this._stepperMcpClients ?? [];
|
|
795
|
+
// Lazily-populated catalog: name → LlmTool.
|
|
796
|
+
let catalogCache;
|
|
797
|
+
const ensureCatalog = async () => {
|
|
798
|
+
if (catalogCache)
|
|
799
|
+
return catalogCache;
|
|
800
|
+
const catalog = new Map();
|
|
801
|
+
await Promise.allSettled(stepperMcpClients.map(async (client) => {
|
|
802
|
+
const result = await client.listTools();
|
|
803
|
+
if (result.ok) {
|
|
804
|
+
for (const t of result.value) {
|
|
805
|
+
if (!catalog.has(t.name)) {
|
|
806
|
+
catalog.set(t.name, t);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}));
|
|
811
|
+
catalogCache = catalog;
|
|
812
|
+
return catalog;
|
|
813
|
+
};
|
|
814
|
+
const toolsRagHandle = {
|
|
815
|
+
async query(text, k) {
|
|
816
|
+
const limit = k ?? 20;
|
|
817
|
+
const catalog = await ensureCatalog();
|
|
818
|
+
// Semantic path: requires both a vector store and an embedder.
|
|
819
|
+
if (toolsRag && resolvedEmbedder) {
|
|
820
|
+
const embedding = new QueryEmbedding(text, resolvedEmbedder);
|
|
821
|
+
const ragResult = await toolsRag.query(embedding, limit);
|
|
822
|
+
if (ragResult.ok) {
|
|
823
|
+
const hits = [];
|
|
824
|
+
for (const r of ragResult.value) {
|
|
825
|
+
const id = r.metadata.id;
|
|
826
|
+
if (id?.startsWith('tool:')) {
|
|
827
|
+
const name = id.slice(5).replace(/:.*$/, '');
|
|
828
|
+
const tool = catalog.get(name);
|
|
829
|
+
if (tool)
|
|
830
|
+
hits.push(tool);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (hits.length > 0)
|
|
834
|
+
return hits;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// Catalog-order fallback: return first `limit` tools from the MCP catalog.
|
|
838
|
+
return [...catalog.values()].slice(0, limit);
|
|
839
|
+
},
|
|
840
|
+
lookup(name) {
|
|
841
|
+
// Synchronous: returns from the in-memory cache populated by ensureCatalog.
|
|
842
|
+
// Returns undefined before the first query() call — callers that need
|
|
843
|
+
// a schema before any query should call query() first.
|
|
844
|
+
return catalogCache?.get(name);
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
// Mint helpers: stable UUIDs per spec §C.1/§C.2.
|
|
848
|
+
// The server wires randomUUID(); tests can inject deterministic minters.
|
|
849
|
+
const mintStepperId = () => randomUUID();
|
|
850
|
+
const mintTurnId = () => randomUUID();
|
|
851
|
+
const stepperMakeLlm = async (lc) => makeLlm({
|
|
852
|
+
provider: lc.provider ?? 'deepseek',
|
|
853
|
+
apiKey: lc.apiKey,
|
|
854
|
+
baseURL: lc.url,
|
|
855
|
+
model: lc.model,
|
|
856
|
+
}, Number(lc.temperature ?? mainTemp));
|
|
857
|
+
// Real callMcp bridge — delegates to the exported buildMcpBridge helper
|
|
858
|
+
// that iterates stepperMcpClients. Exported for testability (B-1).
|
|
859
|
+
const callMcp = buildMcpBridge(stepperMcpClients);
|
|
860
|
+
// DI/test override registry — left empty in production so buildStepperRoot
|
|
861
|
+
// builds the recursive child Steppers itself from `subagents` (below),
|
|
862
|
+
// sharing this run's role LLMs + shared token ledger.
|
|
863
|
+
const stepperRegistry = new Map();
|
|
864
|
+
// Declared subagents (name + description) → advertised to the planner and
|
|
865
|
+
// turned into recursive child Steppers in deep-stepper mode (Finding 1).
|
|
866
|
+
const stepperSubagents = (this.cfg.subAgentConfigs ?? []).map((s) => ({
|
|
867
|
+
name: s.name,
|
|
868
|
+
description: s.description,
|
|
869
|
+
}));
|
|
870
|
+
const stepperHandler = new StepperCoordinatorHandler({
|
|
871
|
+
buildBuilt: async (_ctx, logLlmCall) => {
|
|
872
|
+
// Per-RUN MCP result cache. Identical (tool, args) calls within ONE
|
|
873
|
+
// run reuse the first result instead of re-hitting MCP — fixes the
|
|
874
|
+
// redundant re-reads we saw in flow (gather → analyze → synthesize
|
|
875
|
+
// each fetching the same source/includes) and the latency that
|
|
876
|
+
// caused. Caching the Promise also dedups concurrent identical
|
|
877
|
+
// calls. Fresh per request → never stale across requests.
|
|
878
|
+
const runMcpCache = new Map();
|
|
879
|
+
const cachedCallMcp = (name, args, signal) => {
|
|
880
|
+
const key = `${name}:${stableArgsKey(args)}`;
|
|
881
|
+
const hit = runMcpCache.get(key);
|
|
882
|
+
if (hit)
|
|
883
|
+
return hit;
|
|
884
|
+
const p = callMcp(name, args, signal);
|
|
885
|
+
runMcpCache.set(key, p);
|
|
886
|
+
return p;
|
|
887
|
+
};
|
|
888
|
+
return buildStepperRoot({
|
|
889
|
+
coordCfg: rawCoordCfg,
|
|
890
|
+
registry: stepperRegistry,
|
|
891
|
+
subagents: stepperSubagents,
|
|
892
|
+
makeLlm: stepperMakeLlm,
|
|
893
|
+
knowledgeRagFor: (sid) => {
|
|
894
|
+
const backend = knowledgeBackend ?? new InMemoryKnowledgeBackend();
|
|
895
|
+
return new KnowledgeRag(backend, sid);
|
|
896
|
+
},
|
|
897
|
+
toolsRag: toolsRagHandle,
|
|
898
|
+
callMcp: cachedCallMcp,
|
|
899
|
+
mintStepperId,
|
|
900
|
+
llmMap,
|
|
901
|
+
pipelineFallback,
|
|
902
|
+
logLlmCall,
|
|
903
|
+
});
|
|
904
|
+
},
|
|
905
|
+
knowledgeRagFor,
|
|
906
|
+
toolsRag: toolsRagHandle,
|
|
907
|
+
mintStepperId,
|
|
908
|
+
mintTurnId,
|
|
909
|
+
});
|
|
910
|
+
this._stepperCoordinatorHandler = stepperHandler;
|
|
911
|
+
builder = builder.withStepperCoordinator(stepperHandler);
|
|
912
|
+
log({
|
|
913
|
+
event: 'stepper_coordinator_configured',
|
|
914
|
+
mode: stepperCfg.mode,
|
|
915
|
+
config: rawCoordCfg,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
else if (coordCfg.planner !== undefined) {
|
|
919
|
+
// ── Legacy DAG path (deprecated §K — remove in 19.0) ─────────────────
|
|
920
|
+
log({
|
|
921
|
+
event: 'config_warning',
|
|
922
|
+
message: 'coordinator.planner without coordinator.mode uses the deprecated DagCoordinatorHandler; ' +
|
|
923
|
+
'set coordinator.mode to adopt the 18.0 Stepper runtime',
|
|
924
|
+
});
|
|
925
|
+
// Fail loud if the config mixes DAG and linear fields.
|
|
926
|
+
assertCoordinatorConfigShape(rawCoordCfg);
|
|
507
927
|
// planner shape/type already validated by assertCoordinatorConfigShape.
|
|
508
928
|
// Validate interpreter.type if present — only 'dag' is supported.
|
|
509
929
|
const interpKind = coordCfg.interpreter?.type;
|
|
@@ -511,7 +931,7 @@ export class SmartServer {
|
|
|
511
931
|
throw new Error(`coordinator.interpreter: unknown type '${interpKind}' (only 'dag' is supported)`);
|
|
512
932
|
}
|
|
513
933
|
const built = await buildDagCoordinatorDeps({
|
|
514
|
-
coordCfg:
|
|
934
|
+
coordCfg: rawCoordCfg,
|
|
515
935
|
llmMap,
|
|
516
936
|
pipelineFallback,
|
|
517
937
|
mainLlm,
|
|
@@ -549,6 +969,9 @@ export class SmartServer {
|
|
|
549
969
|
log({ event: 'dag_coordinator_configured', config: coordCfg });
|
|
550
970
|
}
|
|
551
971
|
else {
|
|
972
|
+
// ── Linear / flat coordinator path ────────────────────────────────────
|
|
973
|
+
// Fail loud if config mixes fields with non-linear settings.
|
|
974
|
+
assertCoordinatorConfigShape(rawCoordCfg);
|
|
552
975
|
// Linear mode: route plannerLlm through the normalized map chain.
|
|
553
976
|
// Priority: map[name] → 'helper'/'planner' alias (helperLlm) →
|
|
554
977
|
// pipelineFallback → mainLlm.
|
|
@@ -637,6 +1060,16 @@ export class SmartServer {
|
|
|
637
1060
|
}
|
|
638
1061
|
}
|
|
639
1062
|
const closeFns = [closeAgent];
|
|
1063
|
+
// Stepper-owned MCP clients (connected from YAML mcp: block when no
|
|
1064
|
+
// DI/plugin clients existed). Dispose on server shutdown.
|
|
1065
|
+
// TODO: IMcpClient does not currently expose a close() method; add
|
|
1066
|
+
// `for (const c of this._stepperMcpClients) await c.close?.();`
|
|
1067
|
+
// once the interface gains one.
|
|
1068
|
+
if (this._stepperMcpClients && this._stepperMcpClients.length > 0) {
|
|
1069
|
+
closeFns.push(async () => {
|
|
1070
|
+
this._stepperMcpClients = undefined;
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
640
1073
|
// ---- Per-session lifecycle (cookie identity + graph factory + registry) ----
|
|
641
1074
|
const sessionCfg = this.cfg.session ?? {};
|
|
642
1075
|
const idleTtlMs = sessionCfg.idleTtlMs ?? 7_200_000;
|
|
@@ -1057,7 +1490,12 @@ export class SmartServer {
|
|
|
1057
1490
|
}));
|
|
1058
1491
|
}
|
|
1059
1492
|
b = b.withSubAgents(registry);
|
|
1060
|
-
if (this.
|
|
1493
|
+
if (this._stepperCoordinatorHandler) {
|
|
1494
|
+
// Stepper coordinator is stateless — reuse the same handler instance
|
|
1495
|
+
// across sessions (session context arrives via ctx.sessionId).
|
|
1496
|
+
b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
|
|
1497
|
+
}
|
|
1498
|
+
else if (this._dagCoordinatorTemplate) {
|
|
1061
1499
|
const tpl = this._dagCoordinatorTemplate;
|
|
1062
1500
|
const workers = new Map([...registry].filter(([name]) => name !== tpl.oracleName));
|
|
1063
1501
|
const raw = tpl.oracleName ? registry.get(tpl.oracleName) : undefined;
|
|
@@ -1068,6 +1506,11 @@ export class SmartServer {
|
|
|
1068
1506
|
});
|
|
1069
1507
|
}
|
|
1070
1508
|
}
|
|
1509
|
+
else if (this._stepperCoordinatorHandler) {
|
|
1510
|
+
// No sub-agent configs but stepper coordinator configured — wire it directly.
|
|
1511
|
+
// (Stepper coordinator is stateless and works without sub-agents.)
|
|
1512
|
+
b = b.withStepperCoordinator(this._stepperCoordinatorHandler);
|
|
1513
|
+
}
|
|
1071
1514
|
const handle = await b.build();
|
|
1072
1515
|
return handle.agent;
|
|
1073
1516
|
}
|
|
@@ -1091,10 +1534,25 @@ export class SmartServer {
|
|
|
1091
1534
|
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1092
1535
|
}
|
|
1093
1536
|
const graph = await lifecycle.acquire(sessionId);
|
|
1537
|
+
// Register/touch the session in the meta store so /v1/sessions, resume and
|
|
1538
|
+
// delete reflect real chat/stream traffic (review Finding 3). Best-effort:
|
|
1539
|
+
// a meta-store hiccup must never break the actual request.
|
|
1540
|
+
try {
|
|
1541
|
+
await recordSessionStart(this._sessionMetaStore, sessionId, new Date().toISOString());
|
|
1542
|
+
}
|
|
1543
|
+
catch {
|
|
1544
|
+
// swallow — session metadata is non-critical to serving the request
|
|
1545
|
+
}
|
|
1094
1546
|
try {
|
|
1095
1547
|
await fn(graph, sessionId, traceId);
|
|
1096
1548
|
}
|
|
1097
1549
|
finally {
|
|
1550
|
+
try {
|
|
1551
|
+
await recordSessionEnd(this._sessionMetaStore, sessionId, new Date().toISOString());
|
|
1552
|
+
}
|
|
1553
|
+
catch {
|
|
1554
|
+
// swallow — see above
|
|
1555
|
+
}
|
|
1098
1556
|
graph.logger.dropRequest(traceId);
|
|
1099
1557
|
// Pass the graph instance — `invalidateAll()` may have detached this
|
|
1100
1558
|
// graph into the draining map while the request was in flight; we must
|
|
@@ -1198,6 +1656,86 @@ export class SmartServer {
|
|
|
1198
1656
|
}
|
|
1199
1657
|
return;
|
|
1200
1658
|
}
|
|
1659
|
+
// GET /v1/sessions — list sessions for the current identity
|
|
1660
|
+
if (req.method === 'GET' && urlPath === '/v1/sessions') {
|
|
1661
|
+
const lifecycle = this._lifecycle;
|
|
1662
|
+
if (!lifecycle) {
|
|
1663
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1664
|
+
res.end(jsonError('Session lifecycle not initialized', 'server_error'));
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1668
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1669
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1670
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1671
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1672
|
+
}
|
|
1673
|
+
const identity = resolved.identity.sessionId;
|
|
1674
|
+
const body = await handleListSessions(this._sessionMetaStore, identity);
|
|
1675
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1676
|
+
res.end(JSON.stringify(body));
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
// POST /v1/sessions/:id/resume — resume a session
|
|
1680
|
+
{
|
|
1681
|
+
const resumeMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)\/resume$/);
|
|
1682
|
+
if (req.method === 'POST' && resumeMatch) {
|
|
1683
|
+
const sessionId = resumeMatch[1];
|
|
1684
|
+
const lifecycle = this._lifecycle;
|
|
1685
|
+
if (!lifecycle) {
|
|
1686
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1687
|
+
res.end(jsonError('Session lifecycle not initialized', 'server_error'));
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1691
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1692
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1693
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1694
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1695
|
+
}
|
|
1696
|
+
const identity = resolved.identity.sessionId;
|
|
1697
|
+
const body = await handleResumeSession(this._sessionMetaStore, identity, sessionId);
|
|
1698
|
+
const status = body.ok ? 200 : 404;
|
|
1699
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1700
|
+
res.end(JSON.stringify(body));
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
// DELETE /v1/sessions/:id — delete a session
|
|
1705
|
+
{
|
|
1706
|
+
const deleteMatch = urlPath.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
1707
|
+
if (req.method === 'DELETE' && deleteMatch) {
|
|
1708
|
+
const sessionId = deleteMatch[1];
|
|
1709
|
+
const lifecycle = this._lifecycle;
|
|
1710
|
+
if (!lifecycle) {
|
|
1711
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1712
|
+
res.end(jsonError('Session lifecycle not initialized', 'server_error'));
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const isHttps = req.socket.encrypted === true ||
|
|
1716
|
+
req.headers['x-forwarded-proto'] === 'https';
|
|
1717
|
+
const resolved = lifecycle.resolve(req.headers['cookie'], isHttps);
|
|
1718
|
+
if (resolved.minted && resolved.setCookie) {
|
|
1719
|
+
res.setHeader('Set-Cookie', resolved.setCookie);
|
|
1720
|
+
}
|
|
1721
|
+
const identity = resolved.identity.sessionId;
|
|
1722
|
+
const evictFn = async (sid) => {
|
|
1723
|
+
// (a) Evict/dispose this session's graph from the registry.
|
|
1724
|
+
await lifecycle.registry.evictOne(sid);
|
|
1725
|
+
// (b) Evict the session's knowledge from the shared backend. This
|
|
1726
|
+
// clears the long-lived in-memory backend AND removes the JSONL files
|
|
1727
|
+
// (JsonlKnowledgeBackend.deleteSession), so a same-id re-entry never
|
|
1728
|
+
// rehydrates stale entries — matching the README "evicts its
|
|
1729
|
+
// knowledge-RAG entries" contract.
|
|
1730
|
+
await this._stepperKnowledgeBackend?.deleteSession(sid);
|
|
1731
|
+
};
|
|
1732
|
+
const body = await handleDeleteSession(this._sessionMetaStore, identity, sessionId, evictFn);
|
|
1733
|
+
const status = body.ok ? 200 : 404;
|
|
1734
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1735
|
+
res.end(JSON.stringify(body));
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1201
1739
|
// /v1/config or /config
|
|
1202
1740
|
if (urlPath === '/v1/config' || urlPath === '/config') {
|
|
1203
1741
|
if (req.method === 'GET') {
|