@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.
Files changed (74) hide show
  1. package/README.md +42 -0
  2. package/dist/factories/cyclic-factory.d.ts +48 -0
  3. package/dist/factories/cyclic-factory.d.ts.map +1 -0
  4. package/dist/factories/cyclic-factory.js +35 -0
  5. package/dist/factories/cyclic-factory.js.map +1 -0
  6. package/dist/factories/dag-factory.d.ts +13 -0
  7. package/dist/factories/dag-factory.d.ts.map +1 -0
  8. package/dist/factories/dag-factory.js +14 -0
  9. package/dist/factories/dag-factory.js.map +1 -0
  10. package/dist/factories/deep-stepper-factory.d.ts +12 -0
  11. package/dist/factories/deep-stepper-factory.d.ts.map +1 -0
  12. package/dist/factories/deep-stepper-factory.js +13 -0
  13. package/dist/factories/deep-stepper-factory.js.map +1 -0
  14. package/dist/factories/index.d.ts +7 -0
  15. package/dist/factories/index.d.ts.map +1 -0
  16. package/dist/factories/index.js +6 -0
  17. package/dist/factories/index.js.map +1 -0
  18. package/dist/factories/linear-factory.d.ts +13 -0
  19. package/dist/factories/linear-factory.d.ts.map +1 -0
  20. package/dist/factories/linear-factory.js +14 -0
  21. package/dist/factories/linear-factory.js.map +1 -0
  22. package/dist/factories/planned-factory.d.ts +12 -0
  23. package/dist/factories/planned-factory.d.ts.map +1 -0
  24. package/dist/factories/planned-factory.js +13 -0
  25. package/dist/factories/planned-factory.js.map +1 -0
  26. package/dist/generated/version.d.ts +2 -0
  27. package/dist/generated/version.d.ts.map +1 -0
  28. package/dist/generated/version.js +3 -0
  29. package/dist/generated/version.js.map +1 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +12 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/smart-agent/build-dag-coordinator-deps.d.ts +34 -0
  35. package/dist/smart-agent/build-dag-coordinator-deps.d.ts.map +1 -0
  36. package/dist/smart-agent/build-dag-coordinator-deps.js +107 -0
  37. package/dist/smart-agent/build-dag-coordinator-deps.js.map +1 -0
  38. package/dist/smart-agent/build-stepper-root.d.ts +110 -0
  39. package/dist/smart-agent/build-stepper-root.d.ts.map +1 -0
  40. package/dist/smart-agent/build-stepper-root.js +270 -0
  41. package/dist/smart-agent/build-stepper-root.js.map +1 -0
  42. package/dist/smart-agent/config.d.ts +282 -0
  43. package/dist/smart-agent/config.d.ts.map +1 -0
  44. package/dist/smart-agent/config.js +1109 -0
  45. package/dist/smart-agent/config.js.map +1 -0
  46. package/dist/smart-agent/jsonl-knowledge-backend.d.ts +19 -0
  47. package/dist/smart-agent/jsonl-knowledge-backend.d.ts.map +1 -0
  48. package/dist/smart-agent/jsonl-knowledge-backend.js +46 -0
  49. package/dist/smart-agent/jsonl-knowledge-backend.js.map +1 -0
  50. package/dist/smart-agent/pipeline.d.ts +89 -0
  51. package/dist/smart-agent/pipeline.d.ts.map +1 -0
  52. package/dist/smart-agent/pipeline.js +8 -0
  53. package/dist/smart-agent/pipeline.js.map +1 -0
  54. package/dist/smart-agent/resolve-agent-embedder.d.ts +34 -0
  55. package/dist/smart-agent/resolve-agent-embedder.d.ts.map +1 -0
  56. package/dist/smart-agent/resolve-agent-embedder.js +48 -0
  57. package/dist/smart-agent/resolve-agent-embedder.js.map +1 -0
  58. package/dist/smart-agent/session-identity-resolver.d.ts +17 -0
  59. package/dist/smart-agent/session-identity-resolver.d.ts.map +1 -0
  60. package/dist/smart-agent/session-identity-resolver.js +37 -0
  61. package/dist/smart-agent/session-identity-resolver.js.map +1 -0
  62. package/dist/smart-agent/session-meta-store.d.ts +53 -0
  63. package/dist/smart-agent/session-meta-store.d.ts.map +1 -0
  64. package/dist/smart-agent/session-meta-store.js +36 -0
  65. package/dist/smart-agent/session-meta-store.js.map +1 -0
  66. package/dist/smart-agent/smart-server.d.ts +525 -0
  67. package/dist/smart-agent/smart-server.d.ts.map +1 -0
  68. package/dist/smart-agent/smart-server.js +2309 -0
  69. package/dist/smart-agent/smart-server.js.map +1 -0
  70. package/dist/smart-agent/stepper-coordinator-handler.d.ts +36 -0
  71. package/dist/smart-agent/stepper-coordinator-handler.d.ts.map +1 -0
  72. package/dist/smart-agent/stepper-coordinator-handler.js +230 -0
  73. package/dist/smart-agent/stepper-coordinator-handler.js.map +1 -0
  74. package/package.json +48 -0
@@ -0,0 +1,1109 @@
1
+ /**
2
+ * Shared config utilities for SmartServer.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { AutoActivation, ExplicitActivation, HybridDispatch, LlmFinalizer, OneShotPlanning, PassthroughFinalizer, ReplanOnErrorPlanning, ScoreThresholdToolSelection, SelfDispatch, SkillStepsPlanning, SubAgentDispatch, TemplateFinalizer, TopKToolSelection, } from '@mcp-abap-adt/llm-agent-libs';
7
+ import { parse as parseYaml } from 'yaml';
8
+ /**
9
+ * Detect whether an object is a flat SmartServerLlmConfig shape.
10
+ * Flat shape is identified by the presence of ANY of the known flat-shape
11
+ * fields: `provider`, `apiKey`, `model`, or `url`. This covers keyless
12
+ * providers (Ollama, SAP AI Core) that omit `apiKey` — using `apiKey`-only
13
+ * detection silently misclassified those configs as a "map" shape.
14
+ */
15
+ function isFlatLlmConfig(input) {
16
+ const flat = input;
17
+ return (typeof flat.provider === 'string' ||
18
+ typeof flat.apiKey === 'string' ||
19
+ typeof flat.model === 'string' ||
20
+ typeof flat.url === 'string');
21
+ }
22
+ /**
23
+ * Normalize the optional top-level `llm:` block.
24
+ * - undefined → undefined (pipeline-only configs stay valid)
25
+ * - flat shape (has `provider` | `apiKey` | `model` | `url`) → { main: flat } (backward compat)
26
+ * - map shape → must include `main`; returned as NormalizedLlmMap
27
+ */
28
+ export function normalizeLlmConfig(input) {
29
+ if (input === undefined)
30
+ return undefined;
31
+ if (isFlatLlmConfig(input)) {
32
+ return { main: input };
33
+ }
34
+ const map = input;
35
+ if (!map.main) {
36
+ throw new Error("llm: map must include a 'main' key (default LLM for unspecified roles)");
37
+ }
38
+ return map;
39
+ }
40
+ /**
41
+ * Strict lookup: returns map[name] if explicitly present, else undefined.
42
+ * Does NOT fall through to map.main. Use when the caller needs to
43
+ * detect explicit-presence (e.g. to decide between an alias and the
44
+ * named map entry).
45
+ */
46
+ export function resolveLlmConfigStrict(map, name) {
47
+ if (!map || !name)
48
+ return undefined;
49
+ return map[name];
50
+ }
51
+ /**
52
+ * Resolve a per-role LLM config by name from a normalized map.
53
+ * Lookup chain: map[name] → map.main → pipelineFallback.
54
+ * When map is undefined, falls back to pipelineFallback (so pipeline-only
55
+ * configs keep working with no top-level llm: block).
56
+ *
57
+ * The caller decides whether `undefined` is an error.
58
+ */
59
+ export function resolveLlmConfig(map, name, pipelineFallback) {
60
+ if (!map)
61
+ return pipelineFallback;
62
+ if (!name || name === 'main')
63
+ return map.main;
64
+ return map[name] ?? map.main;
65
+ }
66
+ /**
67
+ * Read the reviewer block's LLM-name selector, accepting both the
68
+ * preferred `reviewerLlm` field and the deprecated `plannerLlm` alias.
69
+ * When the alias is used, calls `warn(message)`.
70
+ */
71
+ export function resolveReviewerLlmName(block, warn) {
72
+ if (!block)
73
+ return undefined;
74
+ if (typeof block.reviewerLlm === 'string')
75
+ return block.reviewerLlm;
76
+ if (typeof block.plannerLlm === 'string') {
77
+ warn("coordinator.reviewer.plannerLlm is deprecated; rename to 'reviewerLlm'");
78
+ return block.plannerLlm;
79
+ }
80
+ return undefined;
81
+ }
82
+ const LINEAR_ONLY = [
83
+ 'planning',
84
+ 'dispatch',
85
+ 'maxSteps',
86
+ 'maxRetriesPerStep',
87
+ 'failPolicy',
88
+ 'maxLayer',
89
+ 'plannerLlm',
90
+ ];
91
+ const DAG_ONLY = [
92
+ 'planner',
93
+ 'interpreter',
94
+ 'reviewer',
95
+ 'errorStrategy',
96
+ 'finalizer',
97
+ 'stateOracle',
98
+ 'maxRoundTrips',
99
+ ];
100
+ /** Validate a coordinator role block shape. */
101
+ function assertLlmRoleShape(label, role) {
102
+ if (typeof role !== 'object' || role === null || Array.isArray(role)) {
103
+ throw new Error(`coordinator.${label} must be an object (e.g. { type: llm }), got: ${JSON.stringify(role)}`);
104
+ }
105
+ const kind = role.type;
106
+ if (kind !== undefined &&
107
+ kind !== 'llm' &&
108
+ !(label === 'finalizer' && (kind === 'passthrough' || kind === 'template'))) {
109
+ throw new Error(`coordinator.${label}: unknown type '${String(kind)}'`);
110
+ }
111
+ for (const field of ['plannerLlm', 'reviewerLlm', 'finalizerLlm']) {
112
+ const sel = role[field];
113
+ if (sel !== undefined && typeof sel !== 'string') {
114
+ throw new Error(`coordinator.${label}.${field} must be a string referencing an llm.* key, got: ${String(sel)}`);
115
+ }
116
+ }
117
+ const sp = role.systemPrompt;
118
+ if (sp !== undefined && typeof sp !== 'string') {
119
+ throw new Error(`coordinator.${label}.systemPrompt must be a string, got: ${String(sp)}`);
120
+ }
121
+ }
122
+ function assertErrorStrategyShape(es) {
123
+ if (typeof es !== 'object' || es === null || Array.isArray(es)) {
124
+ throw new Error(`coordinator.errorStrategy must be an object (e.g. { type: replan }), got: ${JSON.stringify(es)}`);
125
+ }
126
+ const type = es.type;
127
+ if (type !== undefined && type !== 'abort' && type !== 'replan') {
128
+ throw new Error(`coordinator.errorStrategy: unknown type '${String(type)}' (only 'abort' | 'replan')`);
129
+ }
130
+ const mr = es.maxReplans;
131
+ if (mr !== undefined && (typeof mr !== 'number' || mr < 0)) {
132
+ throw new Error(`coordinator.errorStrategy.maxReplans must be a non-negative number, got: ${String(mr)}`);
133
+ }
134
+ }
135
+ /** Fail-loud guard: a coordinator block is either DAG (has `planner`) or linear,
136
+ * never mixed. `activation` is shared and always allowed. */
137
+ export function assertCoordinatorConfigShape(coord) {
138
+ const isDag = coord.planner !== undefined;
139
+ if (isDag) {
140
+ assertLlmRoleShape('planner', coord.planner);
141
+ if (coord.reviewer !== undefined) {
142
+ assertLlmRoleShape('reviewer', coord.reviewer);
143
+ }
144
+ if (coord.finalizer !== undefined) {
145
+ assertLlmRoleShape('finalizer', coord.finalizer);
146
+ }
147
+ if (coord.errorStrategy !== undefined) {
148
+ assertErrorStrategyShape(coord.errorStrategy);
149
+ }
150
+ if (coord.stateOracle !== undefined &&
151
+ typeof coord.stateOracle !== 'string') {
152
+ throw new Error(`coordinator.stateOracle must be a string (a declared subagent name), got: ${JSON.stringify(coord.stateOracle)}`);
153
+ }
154
+ if (coord.maxRoundTrips !== undefined &&
155
+ (typeof coord.maxRoundTrips !== 'number' || coord.maxRoundTrips < 0)) {
156
+ throw new Error(`coordinator.maxRoundTrips must be a non-negative number, got: ${String(coord.maxRoundTrips)}`);
157
+ }
158
+ for (const f of LINEAR_ONLY) {
159
+ if (coord[f] !== undefined) {
160
+ throw new Error(`coordinator: '${f}' is a linear-only field and cannot be combined with 'planner' (DAG mode)`);
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ for (const f of DAG_ONLY) {
166
+ if (coord[f] !== undefined) {
167
+ throw new Error(`coordinator: '${f}' is a DAG-only field; a linear coordinator uses 'planning'/'dispatch'`);
168
+ }
169
+ }
170
+ }
171
+ }
172
+ export function resolveCoordinatorPlanning(name, plannerLlm) {
173
+ switch (name) {
174
+ case 'one-shot':
175
+ return new OneShotPlanning(plannerLlm);
176
+ case 'replan-on-error':
177
+ return new ReplanOnErrorPlanning(plannerLlm);
178
+ case 'skill-steps':
179
+ // SkillStepsPlanning reads `ctx.activeSkillMeta` (populated by
180
+ // CoordinatorHandler from `ctx.selectedSkills`). No planner LLM
181
+ // needed — the plan comes directly from the skill's `steps:` block.
182
+ return new SkillStepsPlanning();
183
+ default:
184
+ throw new Error(`Unknown coordinator.planning strategy: '${name}'. Allowed: one-shot, replan-on-error, skill-steps.`);
185
+ }
186
+ }
187
+ /**
188
+ * Default coordinator dispatch kind. Omitted → 'hybrid' for ALL planning kinds:
189
+ * agentless steps — the synthesized answer-directly step (#155) and skill steps
190
+ * without an explicit `agent:` — need a self-LLM fallback. Pin 'subagent'
191
+ * explicitly for strict subagent-only routing.
192
+ */
193
+ export function resolveCoordinatorDispatchKind(explicit) {
194
+ return explicit ?? 'hybrid';
195
+ }
196
+ export function resolveCoordinatorDispatch(name, fallbackLlm, contextBuilder) {
197
+ switch (name) {
198
+ case 'subagent':
199
+ return new SubAgentDispatch(contextBuilder);
200
+ case 'self':
201
+ if (!fallbackLlm) {
202
+ throw new Error('coordinator.dispatch=self requires a planner or main LLM');
203
+ }
204
+ return new SelfDispatch(fallbackLlm);
205
+ case 'hybrid':
206
+ if (!fallbackLlm) {
207
+ throw new Error('coordinator.dispatch=hybrid requires a planner or main LLM');
208
+ }
209
+ return new HybridDispatch(new SubAgentDispatch(contextBuilder), new SelfDispatch(fallbackLlm));
210
+ default:
211
+ throw new Error(`Unknown coordinator.dispatch strategy: '${name}'. Allowed: subagent, self, hybrid.`);
212
+ }
213
+ }
214
+ export function resolveCoordinatorActivation(name) {
215
+ switch (name) {
216
+ case 'auto':
217
+ return new AutoActivation();
218
+ case 'explicit':
219
+ return new ExplicitActivation();
220
+ default:
221
+ throw new Error(`Unknown coordinator.activation strategy: '${name}'. Allowed: auto, explicit.`);
222
+ }
223
+ }
224
+ export function resolveToolSelectionStrategy(name, params) {
225
+ switch (name) {
226
+ case 'top-k':
227
+ return new TopKToolSelection();
228
+ case 'threshold': {
229
+ const minScore = params?.minScore;
230
+ if (typeof minScore !== 'number') {
231
+ throw new Error("agent.toolSelection.strategy 'threshold' requires a numeric 'minScore'");
232
+ }
233
+ return new ScoreThresholdToolSelection(minScore);
234
+ }
235
+ default:
236
+ throw new Error(`Unknown agent.toolSelection.strategy '${name}'. Allowed: top-k, threshold.`);
237
+ }
238
+ }
239
+ const VALID_PROVIDERS = [
240
+ 'openai',
241
+ 'anthropic',
242
+ 'deepseek',
243
+ 'sap-ai-sdk',
244
+ 'ollama',
245
+ ];
246
+ const VALID_RAG_TYPES = [
247
+ 'in-memory',
248
+ 'qdrant',
249
+ 'hana-vector',
250
+ 'pg-vector',
251
+ ];
252
+ export class ConfigValidationError extends Error {
253
+ constructor(issues) {
254
+ super(`Configuration error in smart-server.yaml:\n${issues
255
+ .map((i) => ` - ${i}`)
256
+ .join('\n')}\nSet these fields in your YAML and restart.`);
257
+ this.name = 'ConfigValidationError';
258
+ }
259
+ }
260
+ export const YAML_TEMPLATE = `port: 4004
261
+ host: 0.0.0.0
262
+
263
+ # Request routing mode:
264
+ # hard — Fully managed context. Ignores client history/system prompt. Uses RAG + internal MCP tools only.
265
+ # pass — Transparent proxy. Logs everything but modifies nothing.
266
+ # smart — Hybrid. Preserves client history but enriches it with RAG context and MCP tools based on analysis. (default)
267
+ mode: smart
268
+
269
+ llm:
270
+ provider: deepseek # deepseek | openai | anthropic | sap-ai-sdk | ollama
271
+ apiKey: \${DEEPSEEK_API_KEY} # not required for ollama / sap-ai-sdk
272
+ model: deepseek-chat
273
+ temperature: 0.7
274
+ classifierTemperature: 0.1
275
+
276
+ rag:
277
+ type: in-memory # in-memory | qdrant | hana-vector | pg-vector
278
+ embedder: ollama # Embedder to use: ollama | openai | sap-ai-core | <custom>
279
+ url: http://localhost:11434
280
+ model: bge-m3
281
+ # resourceGroup: default # SAP AI Core resource group (sap-ai-core embedder)
282
+ # scenario: orchestration # SAP AI Core scenario: orchestration (default) | foundation-models
283
+ # collectionName: llm-agent # Collection/table name (qdrant | hana-vector | pg-vector)
284
+ dedupThreshold: 0.92
285
+ vectorWeight: 0.7 # Semantic similarity weight (0..1)
286
+ keywordWeight: 0.3 # Lexical matching weight (0..1)
287
+
288
+ mcp:
289
+ # type: none | http | stdio
290
+ # To disable MCP, set type to 'none'
291
+ type: http
292
+ url: http://localhost:3001/mcp/stream/http
293
+
294
+ agent:
295
+ externalToolsValidationMode: permissive # permissive | strict
296
+ maxIterations: 10
297
+ maxToolCalls: 30
298
+ toolUnavailableTtlMs: 600000 # Temporary tool blacklist TTL (ms)
299
+ ragQueryK: 10
300
+ # contextBudgetTokens: 4000 # Max tokens for RAG context in system prompt (0 = no limit)
301
+ # semanticHistoryEnabled: false # Enable semantic history via RAG
302
+ # historyRecencyWindow: 4 # Last N messages from client history in LLM context
303
+ # historyTurnSummaryPrompt: "..." # LLM prompt for turn summarization
304
+ showReasoning: false # Explain strategy at start of response
305
+ historyAutoSummarizeLimit: 10 # History length to trigger compression
306
+ queryExpansionEnabled: false # Expand RAG queries with LLM-generated synonyms
307
+ toolResultCacheTtlMs: 300000 # Tool result cache TTL (ms); 0 to disable
308
+ sessionTokenBudget: 0 # Multi-turn token budget; 0 to disable
309
+ # ragTranslateEnabled: true # Translate non-ASCII RAG queries to English (default: true)
310
+ # classificationEnabled: false # Enable for custom pipelines with multi-store routing
311
+ # toolReselectPerIteration: false # Re-select tools via RAG on each tool-loop iteration
312
+ # llmCallStrategy: streaming # streaming | non-streaming | fallback
313
+ # streamMode: full # full | final — streaming behavior for tool loops
314
+ # heartbeatIntervalMs: 5000 # SSE heartbeat interval during tool execution (ms)
315
+ # healthTimeoutMs: 5000 # Health check probe timeout (ms); increase for slow providers (SAP AI Core: 15000)
316
+ # retry: # LLM retry config for 429/5xx errors
317
+ # maxAttempts: 3
318
+ # backoffMs: 1000
319
+ # retryOn: [429, 500, 502, 503]
320
+ # retryOnMidStream: ['SSE stream'] # Substrings triggering mid-stream retry
321
+
322
+ # --- Advanced Multi-Model Pipeline (optional) -------------------------------
323
+ # Use this section to assign different models for different internal tasks.
324
+ # pipeline:
325
+ # llm:
326
+ # main:
327
+ # provider: deepseek # deepseek | openai | anthropic | sap-ai-sdk
328
+ # apiKey: \${DEEPSEEK_API_KEY}
329
+ # model: deepseek-chat
330
+ # temperature: 0.7
331
+ # streaming: true # false to disable streaming for this provider
332
+ # classifier: # optional; if absent, main config is reused
333
+ # provider: deepseek
334
+ # apiKey: \${DEEPSEEK_API_KEY}
335
+ # model: deepseek-chat
336
+ # temperature: 0.1
337
+ # helper: # optional; if absent, main config is reused
338
+ # provider: deepseek
339
+ # apiKey: \${DEEPSEEK_API_KEY}
340
+ # model: deepseek-chat
341
+ # temperature: 0.1
342
+ #
343
+ # rag:
344
+ # tools:
345
+ # type: qdrant
346
+ # url: http://qdrant:6333
347
+ # embedder: openai # ollama | openai | <custom registered name>
348
+ # model: text-embedding-3-small
349
+ # apiKey: \${OPENAI_API_KEY}
350
+ # history:
351
+ # type: in-memory
352
+ #
353
+ # mcp:
354
+ # - type: http
355
+ # url: http://localhost:3001/mcp/stream/http
356
+
357
+ # --- Structured Pipeline (optional) -------------------------------------------
358
+ # Replaces the hardcoded orchestration flow with a YAML-defined stage tree.
359
+ # When absent, the default flow runs unchanged (full backwards compatibility).
360
+ #
361
+ # pipeline:
362
+ # version: "1"
363
+ # stages:
364
+ # - id: classify
365
+ # type: classify
366
+ # - id: summarize
367
+ # type: summarize
368
+ # - id: rag-retrieval
369
+ # type: parallel
370
+ # when: "shouldRetrieve"
371
+ # stages:
372
+ # - { id: translate, type: translate }
373
+ # - { id: expand, type: expand }
374
+ # after:
375
+ # - id: rag-queries
376
+ # type: parallel
377
+ # stages:
378
+ # - { id: tools, type: rag-query, config: { store: tools, k: 10 } }
379
+ # - { id: history, type: rag-query, config: { store: history, k: 5 } }
380
+ # - { id: rerank, type: rerank }
381
+ # - { id: tool-select, type: tool-select }
382
+ # - id: assemble
383
+ # type: assemble
384
+ # - id: tool-loop
385
+ # type: tool-loop
386
+
387
+ # prompts:
388
+ # system: "You are a helpful assistant specialized in SAP ABAP development."
389
+ # classifier: |
390
+ # You are an intent classifier... (see source for full default prompt)
391
+ # reasoning: |
392
+ # IMPORTANT: Always start your response with a brief <reasoning> block...
393
+ # ragTranslate: |
394
+ # Translate the user request to English for search purposes...
395
+ # historySummary: |
396
+ # Summarize the conversation so far...
397
+
398
+ log: smart-server.log # path to log file; omit for stdout
399
+ # logDir: sessions # Directory for detailed session debug logs
400
+ # pluginDir: ./my-plugins # Additional plugin directory (loaded after defaults)
401
+
402
+ # subagents: # Optional: nested agents callable from pipeline
403
+ # - name: code-reviewer # Used as stage config: { agent: code-reviewer }
404
+ # description: | # Optional. Shown to the Coordinator planner LLM
405
+ # Reviews code and returns # so it can pick this agent for the right step.
406
+ # structured JSON.
407
+ # config: ./agents/code-reviewer.yaml
408
+
409
+ # coordinator: # Optional: enable autonomous plan-execute loop
410
+ # planning: one-shot # one-shot | replan-on-error | skill-steps
411
+ # dispatch: subagent # subagent | self | hybrid
412
+ # activation: explicit # explicit (default) | auto
413
+ # plannerLlm: main # main | planner | helper (unused by skill-steps)
414
+ # maxSteps: 12
415
+ # maxRetriesPerStep: 1
416
+ # failPolicy: abort # abort | continue
417
+ # maxLayer: 1 # DEPRECATED — accepted but ignored (nested
418
+ # # dispatch removed; subagents are leaves)
419
+ `;
420
+ export function resolveEnvVars(value, env = process.env) {
421
+ if (typeof value === 'string')
422
+ return value.replace(/\$\{([^}:]+)(?::-(.*?))?\}/g, (_, name, fallback) => env[name] || fallback || '');
423
+ if (Array.isArray(value))
424
+ return value.map((v) => resolveEnvVars(v, env));
425
+ if (value !== null && typeof value === 'object')
426
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [
427
+ k,
428
+ resolveEnvVars(v, env),
429
+ ]));
430
+ return value;
431
+ }
432
+ export function loadYamlConfig(filePath, env = process.env) {
433
+ const raw = fs.readFileSync(filePath, 'utf8');
434
+ return resolveEnvVars(parseYaml(raw), env);
435
+ }
436
+ export function generateConfigTemplate(outputPath) {
437
+ fs.writeFileSync(outputPath, YAML_TEMPLATE, 'utf8');
438
+ }
439
+ /**
440
+ * Build the IFinalizer impl from `coordinator.finalizer:` YAML.
441
+ *
442
+ * Lookup chain for `type: llm`:
443
+ * resolveLlmConfig(llmMap, cfg.finalizerLlm, pipelineFallback)
444
+ * → top-level llm.<name> → llm.main → pipelineFallback (pipeline.llm.main)
445
+ * → ConfigError if all three are missing.
446
+ *
447
+ * Absent block / `type: passthrough` → PassthroughFinalizer.
448
+ * `type: template` → TemplateFinalizer.
449
+ */
450
+ export async function buildFinalizer(cfg, llmMap, pipelineFallback, makeLlm) {
451
+ const kind = cfg?.type ?? 'passthrough';
452
+ if (kind === 'passthrough')
453
+ return new PassthroughFinalizer();
454
+ if (kind === 'template')
455
+ return new TemplateFinalizer();
456
+ // kind === 'llm'
457
+ const resolved = resolveLlmConfig(llmMap, cfg?.finalizerLlm, pipelineFallback);
458
+ if (!resolved) {
459
+ throw new Error('coordinator.finalizer (type: llm) requires an LLM config: provide top-level llm.<name>, llm.main, or pipeline.llm.main');
460
+ }
461
+ const llm = await makeLlm(resolved);
462
+ return new LlmFinalizer(llm, {
463
+ systemPrompt: cfg?.systemPrompt,
464
+ });
465
+ }
466
+ const get = (obj, ...keys) => keys.reduce((o, k) => {
467
+ if (o !== null && typeof o === 'object' && k in o) {
468
+ return o[k];
469
+ }
470
+ return undefined;
471
+ }, obj);
472
+ function checkLlmRole(label, role, requireModel, env, issues) {
473
+ const provider = role?.provider;
474
+ if (!provider) {
475
+ issues.push(`${label}.provider: required (one of: openai, anthropic, deepseek, sap-ai-sdk, ollama)`);
476
+ return;
477
+ }
478
+ // `as readonly string[]` is required so .includes() accepts an arbitrary
479
+ // string; do not "simplify" — it preserves the const-tuple narrowing.
480
+ if (!VALID_PROVIDERS.includes(provider)) {
481
+ issues.push(`${label}.provider: "${provider}" is invalid (one of: openai, anthropic, deepseek, sap-ai-sdk, ollama)`);
482
+ return;
483
+ }
484
+ if (requireModel && !role?.model) {
485
+ issues.push(`${label}.model: required (string)`);
486
+ }
487
+ if (provider === 'openai' ||
488
+ provider === 'anthropic' ||
489
+ provider === 'deepseek') {
490
+ if (!role?.apiKey) {
491
+ issues.push(`${provider} requires ${label}.apiKey to resolve to a non-empty value (typically via \${${provider.toUpperCase()}_API_KEY} env reference).`);
492
+ }
493
+ }
494
+ else if (provider === 'sap-ai-sdk') {
495
+ if (!env.AICORE_SERVICE_KEY) {
496
+ issues.push('sap-ai-sdk requires the AICORE_SERVICE_KEY env var to be set with the SAP AI Core service-key JSON content. None found.');
497
+ }
498
+ }
499
+ // ollama: no credential check.
500
+ }
501
+ function checkRagStore(label, store, issues) {
502
+ if (!store)
503
+ return;
504
+ const ragType = store.type;
505
+ if (!ragType) {
506
+ issues.push(`${label}.type: required (one of: in-memory, qdrant, hana-vector, pg-vector)`);
507
+ }
508
+ else if (ragType === 'ollama' || ragType === 'openai') {
509
+ issues.push(`${label}.type: "${ragType}" is an embedder, not a store — use \`type: in-memory\` with \`embedder: ${ragType}\` (or a real store: qdrant, hana-vector, pg-vector)`);
510
+ }
511
+ else if (!VALID_RAG_TYPES.includes(ragType)) {
512
+ issues.push(`${label}.type: "${ragType}" is invalid (one of: in-memory, qdrant, hana-vector, pg-vector)`);
513
+ }
514
+ else {
515
+ if (ragType === 'qdrant' && !store.url) {
516
+ issues.push(`${label}.url: required for ${label}.type qdrant`);
517
+ }
518
+ if ((ragType === 'hana-vector' || ragType === 'pg-vector') &&
519
+ !store.collectionName) {
520
+ issues.push(`${label}.collectionName: required for ${label}.type ${ragType}`);
521
+ }
522
+ }
523
+ // Blocklist (NOT allowlist): consumers can register custom embedder
524
+ // factories, so only known embedder-less providers are hard-rejected here.
525
+ const embedder = store.embedder;
526
+ if (embedder === 'deepseek' || embedder === 'anthropic') {
527
+ issues.push(`${label}.embedder: "${embedder}" provider has no embedder; embedding-capable providers are ollama, openai, sap-ai-core`);
528
+ }
529
+ // Require model when an embedder is used: vector stores always use an
530
+ // embedder; in-memory uses one only when embedder is explicitly set.
531
+ const usesEmbedder = ragType === 'qdrant' ||
532
+ ragType === 'hana-vector' ||
533
+ ragType === 'pg-vector' ||
534
+ (ragType === 'in-memory' && embedder != null);
535
+ if (usesEmbedder && !store.model) {
536
+ issues.push(`${label}.model: required when an embedder is used (e.g. bge-m3 for ollama)`);
537
+ }
538
+ }
539
+ function validateLlmEntry(label, cfg, required, env, issues) {
540
+ checkLlmRole(label, cfg, required, env, issues);
541
+ }
542
+ function validateResolvedConfig(_resolved, yaml, env) {
543
+ const issues = [];
544
+ const usingPipeline = !!get(yaml, 'pipeline', 'llm', 'main');
545
+ if (usingPipeline) {
546
+ const llmCfg = get(yaml, 'pipeline', 'llm');
547
+ checkLlmRole('pipeline.llm.main', llmCfg?.main, true, env, issues);
548
+ if (llmCfg?.classifier) {
549
+ checkLlmRole('pipeline.llm.classifier', llmCfg.classifier, false, env, issues);
550
+ }
551
+ if (llmCfg?.helper) {
552
+ checkLlmRole('pipeline.llm.helper', llmCfg.helper, false, env, issues);
553
+ }
554
+ }
555
+ else {
556
+ // Read from the raw YAML so we can distinguish flat vs map shape.
557
+ // `resolved.llm` is always constructed as a flat object by resolveSmartServerConfig,
558
+ // so it cannot be used to detect the map shape.
559
+ const rawLlm = get(yaml, 'llm');
560
+ if (rawLlm === undefined) {
561
+ // No top-level llm: AND no pipeline.llm.main → would have been caught
562
+ // by `usingPipeline` branch. Defensive: surface a clear issue.
563
+ issues.push('llm: required when pipeline.llm.main is not configured');
564
+ }
565
+ else if (typeof rawLlm.provider === 'string') {
566
+ // Flat shape — existing behaviour.
567
+ validateLlmEntry('llm', rawLlm, true, env, issues);
568
+ }
569
+ else {
570
+ // Map shape — llm.main is required; every named entry is validated.
571
+ const map = rawLlm;
572
+ if (!map.main) {
573
+ issues.push("llm.main: required when 'llm' is a named map");
574
+ }
575
+ else {
576
+ validateLlmEntry('llm.main', map.main, true, env, issues);
577
+ }
578
+ for (const [name, entry] of Object.entries(map)) {
579
+ if (name === 'main')
580
+ continue;
581
+ validateLlmEntry(`llm.${name}`, entry, true, env, issues);
582
+ }
583
+ }
584
+ }
585
+ if (get(yaml, 'mcp')) {
586
+ const rawMcpVal = yaml.mcp;
587
+ const mcpEntries = Array.isArray(rawMcpVal)
588
+ ? rawMcpVal
589
+ : [rawMcpVal];
590
+ mcpEntries.forEach((entry, i) => {
591
+ const label = Array.isArray(rawMcpVal) ? `mcp[${i}]` : 'mcp';
592
+ const mcpType = entry?.type;
593
+ if (mcpType && !['http', 'stdio', 'none'].includes(mcpType)) {
594
+ issues.push(`${label}.type: "${mcpType}" is invalid (one of: http, stdio, none)`);
595
+ }
596
+ if (mcpType === 'http' && !entry?.url) {
597
+ issues.push(`${label}.url: required when ${label}.type is http`);
598
+ }
599
+ if (mcpType === 'stdio' && !entry?.command) {
600
+ issues.push(`${label}.command: required when ${label}.type is stdio`);
601
+ }
602
+ });
603
+ }
604
+ if (get(yaml, 'rag')) {
605
+ checkRagStore('rag', get(yaml, 'rag'), issues);
606
+ }
607
+ const pipelineRag = get(yaml, 'pipeline', 'rag');
608
+ if (pipelineRag) {
609
+ for (const [name, store] of Object.entries(pipelineRag)) {
610
+ checkRagStore(`pipeline.rag.${name}`, store, issues);
611
+ }
612
+ }
613
+ if (issues.length > 0)
614
+ throw new ConfigValidationError([...new Set(issues)]);
615
+ }
616
+ /**
617
+ * Recursively parse the top-level `subagents:` block from a YAML config.
618
+ *
619
+ * Each entry references a sibling YAML file whose resolved config (sans
620
+ * `subagents:` itself — nested orchestration is rejected) becomes a
621
+ * `SmartServerSubAgentConfig`. Relative `config:` paths are resolved
622
+ * against `configPath`'s directory. A `subagents:` block inside a
623
+ * sub-YAML is rejected to guard against unbounded recursion.
624
+ *
625
+ * Returns `undefined` when the parent YAML has no `subagents:` block or
626
+ * when `configPath` is not provided (relative paths cannot be resolved).
627
+ */
628
+ function parseSubAgents(yaml, configPath, args, env) {
629
+ const raw = yaml.subagents;
630
+ if (!Array.isArray(raw) || raw.length === 0)
631
+ return undefined;
632
+ if (!configPath) {
633
+ throw new Error("subagents: parent YAML must be loaded from a file path so 'config' entries can be resolved");
634
+ }
635
+ const baseDir = path.dirname(path.resolve(configPath));
636
+ const out = [];
637
+ for (const entry of raw) {
638
+ if (!entry ||
639
+ typeof entry !== 'object' ||
640
+ typeof entry.name !== 'string' ||
641
+ typeof entry.config !== 'string') {
642
+ throw new Error(`subagents[]: each entry needs 'name' and 'config' (got ${JSON.stringify(entry)})`);
643
+ }
644
+ const name = entry.name;
645
+ const cfgRel = entry.config;
646
+ const description = entry.description;
647
+ if (description !== undefined && typeof description !== 'string') {
648
+ throw new Error(`subagents[].description must be a string when present (got ${JSON.stringify(description)})`);
649
+ }
650
+ const subConfigPath = path.isAbsolute(cfgRel)
651
+ ? cfgRel
652
+ : path.resolve(baseDir, cfgRel);
653
+ const subYaml = loadYamlConfig(subConfigPath, env);
654
+ if (subYaml.subagents !== undefined) {
655
+ throw new Error(`subagent '${name}' must not define its own 'subagents:' (nested orchestration is not supported)`);
656
+ }
657
+ // Loudly reject fields that the sub-agent builder silently drops today.
658
+ // Keeps the contract honest: if a sub YAML declares these, it gets an
659
+ // error rather than a misleadingly-quiet partial config.
660
+ const unsupported = [];
661
+ if (subYaml.pluginDir !== undefined) {
662
+ unsupported.push('pluginDir');
663
+ }
664
+ if (subYaml.clientAdapter !== undefined) {
665
+ unsupported.push('clientAdapter');
666
+ }
667
+ if (subYaml.circuitBreaker !== undefined) {
668
+ unsupported.push('circuitBreaker');
669
+ }
670
+ const subPipeline = subYaml.pipeline;
671
+ if (subPipeline && typeof subPipeline === 'object') {
672
+ const p = subPipeline;
673
+ if (p.reranker !== undefined)
674
+ unsupported.push('pipeline.reranker');
675
+ if (p.queryExpander !== undefined)
676
+ unsupported.push('pipeline.queryExpander');
677
+ if (p.outputValidator !== undefined)
678
+ unsupported.push('pipeline.outputValidator');
679
+ if (p.rag !== undefined &&
680
+ p.rag !== null &&
681
+ typeof p.rag === 'object' &&
682
+ !Array.isArray(p.rag)) {
683
+ unsupported.push('pipeline.rag');
684
+ }
685
+ }
686
+ if (unsupported.length > 0) {
687
+ throw new Error(`subagent '${name}': unsupported fields [${unsupported.join(', ')}]`);
688
+ }
689
+ // Recursive call — we just verified the sub YAML has no `subagents:`, so
690
+ // the parseSubAgents call inside will short-circuit to undefined.
691
+ const subResolved = resolveSmartServerConfig(args, subYaml, env, {
692
+ configPath: subConfigPath,
693
+ });
694
+ out.push({ name, description, config: subResolved });
695
+ }
696
+ return out;
697
+ }
698
+ export function resolveSmartServerConfig(args = {}, yaml = {}, env = process.env, options = {}) {
699
+ const flatApiKey = get(yaml, 'llm', 'apiKey') ?? '';
700
+ const pipelineApiKey = get(yaml, 'pipeline', 'llm', 'main', 'apiKey');
701
+ const apiKey = flatApiKey || pipelineApiKey || '';
702
+ const rawMcp = yaml.mcp;
703
+ const mcpIsArray = Array.isArray(rawMcp);
704
+ const mcpUrl = get(yaml, 'mcp', 'url');
705
+ const mcpCommand = get(yaml, 'mcp', 'command');
706
+ const mcpTypeRaw = mcpIsArray
707
+ ? null // array form: type resolved per-entry inside connectMcpClientsFromConfig
708
+ : (get(yaml, 'mcp', 'type') ??
709
+ (mcpUrl ? 'http' : mcpCommand ? 'stdio' : null));
710
+ const mcpType = (mcpTypeRaw === 'none' ? null : mcpTypeRaw);
711
+ const promptSystem = get(yaml, 'prompts', 'system') ?? null;
712
+ const promptClassifier = get(yaml, 'prompts', 'classifier') ?? null;
713
+ const promptReasoning = get(yaml, 'prompts', 'reasoning') ?? null;
714
+ const promptRagTranslate = get(yaml, 'prompts', 'ragTranslate') ?? null;
715
+ const promptHistorySummary = get(yaml, 'prompts', 'historySummary') ?? null;
716
+ const resolved = {
717
+ port: Number(args.port ?? get(yaml, 'port') ?? env.PORT ?? 4004),
718
+ host: args.host ?? get(yaml, 'host') ?? '0.0.0.0',
719
+ llm: get(yaml, 'llm')
720
+ ? typeof get(yaml, 'llm', 'provider') === 'string'
721
+ ? {
722
+ provider: get(yaml, 'llm', 'provider'),
723
+ apiKey,
724
+ url: get(yaml, 'llm', 'url'),
725
+ model: get(yaml, 'llm', 'model'),
726
+ temperature: Number(get(yaml, 'llm', 'temperature') ?? 0.7),
727
+ classifierTemperature: Number(get(yaml, 'llm', 'classifierTemperature') ?? 0.1),
728
+ }
729
+ : get(yaml, 'llm')
730
+ : undefined,
731
+ rag: get(yaml, 'rag')
732
+ ? {
733
+ type: get(yaml, 'rag', 'type'),
734
+ embedder: get(yaml, 'rag', 'embedder') ?? undefined,
735
+ url: get(yaml, 'rag', 'url'),
736
+ model: get(yaml, 'rag', 'model'),
737
+ collectionName: args['rag-collection-name'] ??
738
+ get(yaml, 'rag', 'collectionName') ??
739
+ undefined,
740
+ dedupThreshold: Number(get(yaml, 'rag', 'dedupThreshold') ?? 0.92),
741
+ vectorWeight: Number(get(yaml, 'rag', 'vectorWeight') ?? 0.7),
742
+ keywordWeight: Number(get(yaml, 'rag', 'keywordWeight') ?? 0.3),
743
+ ...(get(yaml, 'rag', 'resourceGroup') !== undefined
744
+ ? { resourceGroup: String(get(yaml, 'rag', 'resourceGroup')) }
745
+ : {}),
746
+ ...(get(yaml, 'rag', 'scenario') !== undefined
747
+ ? {
748
+ scenario: String(get(yaml, 'rag', 'scenario')),
749
+ }
750
+ : {}),
751
+ }
752
+ : undefined,
753
+ mcp: mcpIsArray
754
+ ? // Array form: pass through as-is so connectMcpClientsFromConfig can
755
+ // iterate and connect each entry. Typed as SmartServerMcpConfig[].
756
+ rawMcp
757
+ : mcpType
758
+ ? {
759
+ type: mcpType,
760
+ url: mcpUrl || undefined,
761
+ command: mcpCommand || undefined,
762
+ args: args['mcp-args'] || get(yaml, 'mcp', 'args')
763
+ ? String(args['mcp-args'] || get(yaml, 'mcp', 'args')).split(' ')
764
+ : undefined,
765
+ headers: get(yaml, 'mcp', 'headers') ||
766
+ undefined,
767
+ }
768
+ : undefined,
769
+ agent: {
770
+ externalToolsValidationMode: (get(yaml, 'agent', 'externalToolsValidationMode') ?? 'permissive'),
771
+ maxIterations: Number(get(yaml, 'agent', 'maxIterations') ?? 10),
772
+ maxToolCalls: Number(get(yaml, 'agent', 'maxToolCalls') ?? 30),
773
+ toolUnavailableTtlMs: Number(get(yaml, 'agent', 'toolUnavailableTtlMs') ?? 600000),
774
+ ragQueryK: Number(get(yaml, 'agent', 'ragQueryK') ?? 10),
775
+ ...(get(yaml, 'agent', 'contextBudgetTokens') !== undefined
776
+ ? {
777
+ contextBudgetTokens: Number(get(yaml, 'agent', 'contextBudgetTokens')),
778
+ }
779
+ : {}),
780
+ ...(get(yaml, 'agent', 'semanticHistoryEnabled') !== undefined
781
+ ? {
782
+ semanticHistoryEnabled: Boolean(get(yaml, 'agent', 'semanticHistoryEnabled')),
783
+ }
784
+ : {}),
785
+ ...(get(yaml, 'agent', 'historyRecencyWindow') !== undefined
786
+ ? {
787
+ historyRecencyWindow: Number(get(yaml, 'agent', 'historyRecencyWindow')),
788
+ }
789
+ : {}),
790
+ ...(get(yaml, 'agent', 'historyTurnSummaryPrompt') !== undefined
791
+ ? {
792
+ historyTurnSummaryPrompt: String(get(yaml, 'agent', 'historyTurnSummaryPrompt')),
793
+ }
794
+ : {}),
795
+ showReasoning: Boolean(args['agent-show-reasoning'] ??
796
+ get(yaml, 'agent', 'showReasoning') ??
797
+ false),
798
+ historyAutoSummarizeLimit: Number(get(yaml, 'agent', 'historyAutoSummarizeLimit') ?? 10),
799
+ queryExpansionEnabled: Boolean(get(yaml, 'agent', 'queryExpansionEnabled') ?? false),
800
+ toolResultCacheTtlMs: Number(get(yaml, 'agent', 'toolResultCacheTtlMs') ?? 300000),
801
+ sessionTokenBudget: Number(get(yaml, 'agent', 'sessionTokenBudget') ?? 0),
802
+ ...(get(yaml, 'agent', 'classificationEnabled') !== undefined
803
+ ? {
804
+ classificationEnabled: Boolean(get(yaml, 'agent', 'classificationEnabled')),
805
+ }
806
+ : {}),
807
+ ...(get(yaml, 'agent', 'toolReselectPerIteration') !== undefined
808
+ ? {
809
+ toolReselectPerIteration: Boolean(get(yaml, 'agent', 'toolReselectPerIteration')),
810
+ }
811
+ : {}),
812
+ ...(get(yaml, 'agent', 'ragTranslateEnabled') !== undefined
813
+ ? {
814
+ ragTranslateEnabled: Boolean(get(yaml, 'agent', 'ragTranslateEnabled')),
815
+ }
816
+ : {}),
817
+ ...(get(yaml, 'agent', 'refreshToolsPerIteration') !== undefined
818
+ ? {
819
+ refreshToolsPerIteration: Boolean(get(yaml, 'agent', 'refreshToolsPerIteration')),
820
+ }
821
+ : {}),
822
+ ...(get(yaml, 'agent', 'streamMode') !== undefined
823
+ ? {
824
+ streamMode: String(get(yaml, 'agent', 'streamMode')),
825
+ }
826
+ : {}),
827
+ ...(get(yaml, 'agent', 'llmCallStrategy') !== undefined
828
+ ? {
829
+ llmCallStrategy: String(get(yaml, 'agent', 'llmCallStrategy')),
830
+ }
831
+ : {}),
832
+ ...(get(yaml, 'agent', 'heartbeatIntervalMs') !== undefined
833
+ ? {
834
+ heartbeatIntervalMs: Number(get(yaml, 'agent', 'heartbeatIntervalMs')),
835
+ }
836
+ : {}),
837
+ ...(get(yaml, 'agent', 'healthTimeoutMs') !== undefined
838
+ ? {
839
+ healthTimeoutMs: Number(get(yaml, 'agent', 'healthTimeoutMs')),
840
+ }
841
+ : {}),
842
+ ...(get(yaml, 'agent', 'retry') !== undefined
843
+ ? {
844
+ retry: get(yaml, 'agent', 'retry'),
845
+ }
846
+ : {}),
847
+ ...(get(yaml, 'agent', 'toolSelection') !== undefined
848
+ ? {
849
+ toolSelection: get(yaml, 'agent', 'toolSelection'),
850
+ }
851
+ : {}),
852
+ },
853
+ prompts: promptSystem ||
854
+ promptClassifier ||
855
+ promptReasoning ||
856
+ promptRagTranslate ||
857
+ promptHistorySummary
858
+ ? {
859
+ ...(promptSystem ? { system: promptSystem } : {}),
860
+ ...(promptClassifier ? { classifier: promptClassifier } : {}),
861
+ ...(typeof promptReasoning === 'string'
862
+ ? { reasoning: promptReasoning }
863
+ : {}),
864
+ ...(typeof promptRagTranslate === 'string'
865
+ ? { ragTranslate: promptRagTranslate }
866
+ : {}),
867
+ ...(typeof promptHistorySummary === 'string'
868
+ ? { historySummary: promptHistorySummary }
869
+ : {}),
870
+ }
871
+ : undefined,
872
+ mode: get(yaml, 'mode') ?? undefined,
873
+ logDir: args['log-dir'] ?? get(yaml, 'logDir') ?? null,
874
+ pluginDir: args['plugin-dir'] ?? get(yaml, 'pluginDir') ?? undefined,
875
+ ...(() => {
876
+ const subAgentConfigs = parseSubAgents(yaml, options.configPath, args, env);
877
+ return subAgentConfigs ? { subAgentConfigs } : {};
878
+ })(),
879
+ ...(() => {
880
+ const coordinatorYaml = yaml
881
+ .coordinator;
882
+ return coordinatorYaml ? { coordinatorYaml } : {};
883
+ })(),
884
+ ...(yaml.pipeline ? { pipeline: yaml.pipeline } : {}),
885
+ ...(yaml.skills
886
+ ? {
887
+ skills: {
888
+ type: (get(yaml, 'skills', 'type') ?? 'claude'),
889
+ dirs: get(yaml, 'skills', 'dirs'),
890
+ projectRoot: get(yaml, 'skills', 'projectRoot'),
891
+ },
892
+ }
893
+ : {}),
894
+ };
895
+ validateResolvedConfig(resolved, yaml, env);
896
+ return resolved;
897
+ }
898
+ const MODES = new Set([
899
+ 'cyclic-react',
900
+ 'planned-react',
901
+ 'deep-stepper',
902
+ ]);
903
+ /**
904
+ * Preset expansion: each `mode` maps to a default `flow` composition.
905
+ * An explicit `coordinator.flow` block overrides these per-component.
906
+ * `deep-stepper` = llm planner + RECURSIVE executor (demand-driven recursion),
907
+ * relying on the Evaluator as terminator (enforced below).
908
+ */
909
+ const MODE_FLOW_PRESET = {
910
+ 'cyclic-react': { planner: 'none', executor: 'cyclic-react' },
911
+ 'planned-react': { planner: 'llm', executor: 'cyclic-react' },
912
+ 'deep-stepper': { planner: 'llm', executor: 'recursive' },
913
+ };
914
+ /** Parse declarative `flow.plan` nodes (for the static planner). */
915
+ function parseFlowPlan(raw) {
916
+ if (!Array.isArray(raw))
917
+ return undefined;
918
+ const nodes = raw
919
+ .filter((n) => !!n && typeof n.goal === 'string')
920
+ .map((n, i) => ({
921
+ id: typeof n.id === 'string' && n.id ? n.id : `n${i}`,
922
+ goal: n.goal,
923
+ ...(Array.isArray(n.dependsOn)
924
+ ? {
925
+ dependsOn: n.dependsOn.filter((d) => typeof d === 'string'),
926
+ }
927
+ : {}),
928
+ ...(typeof n.agent === 'string' ? { agent: n.agent } : {}),
929
+ }));
930
+ return nodes.length > 0 ? nodes : undefined;
931
+ }
932
+ /**
933
+ * Parse a (possibly nested) `flow` block into a full StepperCompositionSpec,
934
+ * inheriting bounds from the root. Mutually recursive with
935
+ * parseCompositionNodes (function declarations are hoisted).
936
+ */
937
+ function parseNestedFlowSpec(flowCfg, bounds) {
938
+ const plannerType = flowCfg?.planner?.type ?? 'llm';
939
+ if (!['none', 'llm', 'static'].includes(plannerType))
940
+ throw new Error(`flow.planner.type must be none|llm|static`);
941
+ const granularity = flowCfg?.planner?.granularity ?? 'shallow';
942
+ if (!['shallow', 'detailed'].includes(granularity))
943
+ throw new Error(`flow.planner.granularity must be shallow|detailed`);
944
+ const executor = flowCfg?.executor?.type ?? 'cyclic-react';
945
+ if (!['simple', 'cyclic-react', 'recursive'].includes(executor))
946
+ throw new Error(`flow.executor.type must be simple|cyclic-react|recursive`);
947
+ const plannerSystemPrompt = parseSystemPromptOverride(flowCfg?.planner?.systemPrompt, 'flow.planner.systemPrompt');
948
+ const executorSystemPrompt = parseSystemPromptOverride(flowCfg?.executor?.systemPrompt, 'flow.executor.systemPrompt');
949
+ const plan = parseFlowPlan(flowCfg?.plan);
950
+ const nodes = parseCompositionNodes(flowCfg?.nodes, bounds);
951
+ return {
952
+ // Declared nodes ARE the plan ⇒ this level is static (keep the spec honest:
953
+ // buildFromComposition routes a node-bearing level to a StaticPlanner).
954
+ planner: (nodes ? 'static' : plannerType),
955
+ granularity: granularity,
956
+ ...(plan ? { plan } : {}),
957
+ ...(nodes ? { nodes } : {}),
958
+ executor: executor,
959
+ finalizer: 'llm',
960
+ ...(plannerSystemPrompt ? { plannerSystemPrompt } : {}),
961
+ ...(executorSystemPrompt ? { executorSystemPrompt } : {}),
962
+ ...bounds,
963
+ };
964
+ }
965
+ /** Validate an optional system-prompt override: must be a non-empty string. */
966
+ function parseSystemPromptOverride(raw, label) {
967
+ if (raw === undefined || raw === null)
968
+ return undefined;
969
+ if (typeof raw !== 'string' || raw.trim() === '')
970
+ throw new Error(`coordinator.${label} must be a non-empty string`);
971
+ return raw;
972
+ }
973
+ /** Parse composition nodes; a node with a nested `flow` recurses into a sub-spec. */
974
+ function parseCompositionNodes(raw, bounds) {
975
+ if (!Array.isArray(raw))
976
+ return undefined;
977
+ const nodes = raw
978
+ .filter((n) => !!n && typeof n.goal === 'string')
979
+ .map((n, i) => ({
980
+ id: typeof n.id === 'string' && n.id ? n.id : `n${i}`,
981
+ goal: n.goal,
982
+ ...(Array.isArray(n.dependsOn)
983
+ ? {
984
+ dependsOn: n.dependsOn.filter((d) => typeof d === 'string'),
985
+ }
986
+ : {}),
987
+ ...(n.flow ? { flow: parseNestedFlowSpec(n.flow, bounds) } : {}),
988
+ }));
989
+ return nodes.length > 0 ? nodes : undefined;
990
+ }
991
+ /**
992
+ * Parse stepper coordinator configuration from a raw config object.
993
+ *
994
+ * Supports:
995
+ * - `mode` (string) — default 'planned-react'; one of cyclic-react | planned-react
996
+ * - `stepper.maxParallelSteps` (number) — default 4
997
+ * - `stepper.maxDepth` (number) — default 4
998
+ * - `stepper.tokenBudget` (number) — default 1,000,000
999
+ * - `stepper.reviewer.atDepths` (number[] | 'all') — default [0,1]; 'all' means accept any depth
1000
+ */
1001
+ export function parseStepperCoordinatorConfig(coord) {
1002
+ const mode = coord.mode ?? 'planned-react';
1003
+ if (!MODES.has(mode))
1004
+ throw new Error(`unknown coordinator.mode '${String(coord.mode)}'`);
1005
+ // Tool permissioning is the MCP SERVER's responsibility — whatever it exposes
1006
+ // via tools/list is allowed. The agent does not classify tools (read-only vs
1007
+ // mutating); there is no agent-side gate. The consumer wires the agent to a
1008
+ // server that exposes only the permitted tools (e.g. a read-only MCP proxy).
1009
+ const stepper = coord.stepper ?? {};
1010
+ const reviewerCfg = stepper.reviewer ?? {};
1011
+ const atDepths = reviewerCfg.atDepths ?? [0, 1];
1012
+ const reviewerAtDepths = atDepths === 'all'
1013
+ ? { has: () => true }
1014
+ : (() => {
1015
+ const s = new Set(atDepths);
1016
+ return { has: (d) => s.has(d) };
1017
+ })();
1018
+ const knowledgeSeed = Array.isArray(coord.knowledgeSeed)
1019
+ ? coord.knowledgeSeed
1020
+ .filter((e) => e && typeof e.content === 'string' && e.content.trim() !== '')
1021
+ .map((e) => ({
1022
+ content: e.content,
1023
+ artifactType: typeof e.artifactType === 'string' && e.artifactType
1024
+ ? e.artifactType
1025
+ : 'guidance',
1026
+ }))
1027
+ : [];
1028
+ // Resolve the program flow: explicit `coordinator.flow` overrides the
1029
+ // mode-derived preset per component. `mode` thus becomes a preset alias.
1030
+ const preset = MODE_FLOW_PRESET[mode];
1031
+ const flowCfg = coord.flow;
1032
+ const plannerType = flowCfg?.planner?.type ?? preset.planner;
1033
+ if (!['none', 'llm', 'static'].includes(plannerType))
1034
+ throw new Error(`coordinator.flow.planner.type must be none|llm|static`);
1035
+ const granularity = flowCfg?.planner?.granularity ?? 'shallow';
1036
+ if (!['shallow', 'detailed'].includes(granularity))
1037
+ throw new Error(`coordinator.flow.planner.granularity must be shallow|detailed`);
1038
+ const executorType = flowCfg?.executor?.type ?? preset.executor;
1039
+ if (!['simple', 'cyclic-react', 'recursive'].includes(executorType))
1040
+ throw new Error(`coordinator.flow.executor.type must be simple|cyclic-react|recursive`);
1041
+ const finalizerType = flowCfg?.finalizer?.type ?? 'llm';
1042
+ if (finalizerType !== 'llm')
1043
+ throw new Error(`coordinator.flow.finalizer.type 'passthrough' is not yet implemented (use 'llm')`);
1044
+ const plannerSystemPrompt = parseSystemPromptOverride(flowCfg?.planner?.systemPrompt, 'flow.planner.systemPrompt');
1045
+ const executorSystemPrompt = parseSystemPromptOverride(flowCfg?.executor?.systemPrompt, 'flow.executor.systemPrompt');
1046
+ // 18.1 Evaluator: ON by default at all depths (per design). Disable via
1047
+ // `flow.evaluator.enabled: false`; narrow via `flow.evaluator.atDepths`.
1048
+ const evaluatorEnabled = flowCfg?.evaluator?.enabled !== false;
1049
+ // RUNAWAY GUARD: demand-driven recursion (executor:recursive / deep-stepper)
1050
+ // terminates via the Evaluator (executable → leaf, needs-work → recurse).
1051
+ // Without it recursion has no termination judge — that is exactly the 18.0
1052
+ // runaway (141 spawns). So recursion REQUIRES the Evaluator enabled.
1053
+ if (executorType === 'recursive' && !evaluatorEnabled)
1054
+ throw new Error('coordinator.flow.executor.type "recursive" (deep-stepper) requires the Evaluator ' +
1055
+ '(it is the recursion terminator) — do not set coordinator.flow.evaluator.enabled: false');
1056
+ const evalAtDepths = flowCfg?.evaluator?.atDepths ?? 'all';
1057
+ const evaluatorAtDepths = evalAtDepths === 'all'
1058
+ ? { has: () => true }
1059
+ : (() => {
1060
+ const s = new Set(evalAtDepths);
1061
+ return { has: (d) => s.has(d) };
1062
+ })();
1063
+ const evaluatorSystemPrompt = parseSystemPromptOverride(flowCfg?.evaluator?.systemPrompt, 'flow.evaluator.systemPrompt');
1064
+ const plan = parseFlowPlan(flowCfg?.plan);
1065
+ const maxParallelSteps = Number(stepper.maxParallelSteps ?? 4);
1066
+ const maxDepth = Number(stepper.maxDepth ?? 4);
1067
+ const tokenBudget = Number(stepper.tokenBudget ?? 1_000_000);
1068
+ const formalizeTask = coord.formalizeTask === true;
1069
+ // Nested composition nodes inherit the root bounds (a sub-cycle uses the same
1070
+ // parallelism / depth / budget / safety unless the runtime threads otherwise).
1071
+ const bounds = {
1072
+ reviewerAtDepths,
1073
+ evaluatorEnabled,
1074
+ evaluatorAtDepths,
1075
+ ...(evaluatorSystemPrompt ? { evaluatorSystemPrompt } : {}),
1076
+ maxParallelSteps,
1077
+ maxDepth,
1078
+ tokenBudget,
1079
+ formalizeTask,
1080
+ };
1081
+ const nodes = parseCompositionNodes(flowCfg?.nodes, bounds);
1082
+ // Static planner needs an explicit plan OR declared nodes (nodes ARE the plan).
1083
+ if (plannerType === 'static' && !plan && !nodes)
1084
+ throw new Error(`coordinator.flow.planner.type 'static' requires coordinator.flow.plan or coordinator.flow.nodes`);
1085
+ return {
1086
+ mode,
1087
+ reviewerAtDepths,
1088
+ maxParallelSteps,
1089
+ maxDepth,
1090
+ tokenBudget,
1091
+ knowledgeSeed,
1092
+ formalizeTask,
1093
+ flow: {
1094
+ // Declared root nodes ARE the plan ⇒ static at the root (honest spec).
1095
+ planner: (nodes ? 'static' : plannerType),
1096
+ granularity: granularity,
1097
+ executor: executorType,
1098
+ finalizer: 'llm',
1099
+ ...(plannerSystemPrompt ? { plannerSystemPrompt } : {}),
1100
+ ...(executorSystemPrompt ? { executorSystemPrompt } : {}),
1101
+ evaluatorEnabled,
1102
+ evaluatorAtDepths,
1103
+ ...(evaluatorSystemPrompt ? { evaluatorSystemPrompt } : {}),
1104
+ ...(plan ? { plan } : {}),
1105
+ ...(nodes ? { nodes } : {}),
1106
+ },
1107
+ };
1108
+ }
1109
+ //# sourceMappingURL=config.js.map