@ema.co/mcp-toolkit 2026.1.27 → 2026.1.28-2
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.
Potentially problematic release.
This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.
- package/dist/mcp/handlers/data/index.js +3 -0
- package/dist/mcp/handlers/persona/create.js +16 -0
- package/dist/mcp/handlers/persona/list.js +9 -4
- package/dist/mcp/handlers/persona/update.js +24 -2
- package/dist/mcp/handlers/workflow/deploy.js +20 -2
- package/dist/mcp/handlers/workflow/generate.js +39 -2
- package/dist/mcp/handlers/workflow/index.js +8 -3
- package/dist/mcp/handlers/workflow/modify.js +34 -7
- package/dist/mcp/handlers/workflow/validate.js +85 -0
- package/dist/mcp/handlers/workflow/validation.js +160 -0
- package/dist/mcp/resources.js +286 -4
- package/dist/mcp/server.js +16 -3
- package/dist/mcp/tools.js +32 -11
- package/dist/sdk/client.js +36 -9
- package/dist/sdk/ema-client.js +32 -4
- package/dist/sdk/index.js +3 -1
- package/dist/sdk/knowledge.js +5 -5
- package/dist/sdk/structural-rules.js +498 -0
- package/dist/sdk/workflow-generator.js +2 -1
- package/dist/sdk/workflow-intent.js +28 -96
- package/dist/sdk/workflow-path-enumerator.js +278 -0
- package/dist/sdk/workflow-static-validator.js +291 -0
- package/dist/sdk/workflow-validation-types.js +7 -0
- package/docs/README.md +14 -0
- package/docs/go-validator-analysis.md +323 -0
- package/docs/rule-format-specification.md +346 -0
- package/docs/validation-contract.md +397 -0
- package/docs/validation-error-format.md +326 -0
- package/package.json +1 -1
- package/dist/mcp/workflow-operations.js +0 -100
- package/dist/sdk/workflow-fixer.js +0 -48
- package/docs/dashboard-operations.md +0 -281
- package/docs/ema-user-guide.md +0 -1201
- package/docs/email-patterns.md +0 -120
- package/docs/mcp-tools-guide.md +0 -575
|
@@ -162,6 +162,9 @@ export async function handleData(args, client, readFile) {
|
|
|
162
162
|
path: filePath,
|
|
163
163
|
widget_name: widgetName ?? "fileUpload",
|
|
164
164
|
...result,
|
|
165
|
+
_warning: "IMPORTANT: Uploaded documents will NOT be used unless your workflow has a search node (search/v2).",
|
|
166
|
+
_next_step: `Verify workflow has search: workflow(mode='get', persona_id='${personaId}') → check for search node. If missing, add one.`,
|
|
167
|
+
_validation: "Deploy will BLOCK if you have documents but no search node in your workflow.",
|
|
165
168
|
};
|
|
166
169
|
}
|
|
167
170
|
catch (error) {
|
|
@@ -221,6 +221,7 @@ export async function handleCreate(args, client, getTemplateId) {
|
|
|
221
221
|
sourcePersonaType,
|
|
222
222
|
dashboardCloneResult,
|
|
223
223
|
actionsError: validation.errors.join("; "),
|
|
224
|
+
createdFromTemplate: fromType === "template",
|
|
224
225
|
});
|
|
225
226
|
}
|
|
226
227
|
// Build execution context
|
|
@@ -243,6 +244,7 @@ export async function handleCreate(args, client, getTemplateId) {
|
|
|
243
244
|
sourcePersonaType,
|
|
244
245
|
// Don't include dashboardCloneResult - actions handle data operations
|
|
245
246
|
actionsResult,
|
|
247
|
+
createdFromTemplate: fromType === "template",
|
|
246
248
|
});
|
|
247
249
|
}
|
|
248
250
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -264,6 +266,7 @@ export async function handleCreate(args, client, getTemplateId) {
|
|
|
264
266
|
workflowError,
|
|
265
267
|
sourcePersonaType,
|
|
266
268
|
dashboardCloneResult,
|
|
269
|
+
createdFromTemplate: fromType === "template",
|
|
267
270
|
});
|
|
268
271
|
}
|
|
269
272
|
return buildCreateResult({
|
|
@@ -274,6 +277,7 @@ export async function handleCreate(args, client, getTemplateId) {
|
|
|
274
277
|
workflowError,
|
|
275
278
|
sourcePersonaType,
|
|
276
279
|
dashboardCloneResult,
|
|
280
|
+
createdFromTemplate: fromType === "template",
|
|
277
281
|
});
|
|
278
282
|
}
|
|
279
283
|
/**
|
|
@@ -299,6 +303,18 @@ function buildCreateResult(opts) {
|
|
|
299
303
|
if (opts.sourcePersonaType) {
|
|
300
304
|
result.source_persona_type = opts.sourcePersonaType;
|
|
301
305
|
}
|
|
306
|
+
// ── CRITICAL GUIDANCE: Template workflows are minimal starters ──
|
|
307
|
+
// This is where LLMs often go wrong - they create from template and think they're done
|
|
308
|
+
if (opts.createdFromTemplate && !opts.workflowApplied) {
|
|
309
|
+
result._warning = "PERSONA CREATED BUT WORKFLOW IS INCOMPLETE. Template workflows are minimal starters (just trigger→respond).";
|
|
310
|
+
result._required_next_steps = [
|
|
311
|
+
"1. BUILD WORKFLOW: Add intent categorization, search nodes, response handling",
|
|
312
|
+
`2. If uploading docs: Workflow MUST have search/v2 node or documents will NOT be used`,
|
|
313
|
+
`3. Get current workflow: workflow(mode='get', persona_id='${opts.newPersonaId}')`,
|
|
314
|
+
`4. Deploy complete workflow: workflow(mode='deploy', persona_id='${opts.newPersonaId}', workflow_def={...})`,
|
|
315
|
+
];
|
|
316
|
+
result._common_mistake = "Creating from template, uploading docs, and declaring 'done' WITHOUT building the workflow. The deploy will now BLOCK this pattern.";
|
|
317
|
+
}
|
|
302
318
|
if (opts.dashboardCloneResult) {
|
|
303
319
|
result.dashboard_data_clone = opts.dashboardCloneResult;
|
|
304
320
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Persona LIST mode handler
|
|
3
3
|
*
|
|
4
4
|
* Lists all personas with optional filtering.
|
|
5
|
+
* Fetches all pages from the API (no artificial limit).
|
|
5
6
|
*/
|
|
6
7
|
import { getTemplates, normalizeTriggerType } from "../utils.js";
|
|
7
8
|
/**
|
|
@@ -10,10 +11,12 @@ import { getTemplates, normalizeTriggerType } from "../utils.js";
|
|
|
10
11
|
* @param args.query - Filter by name (partial match)
|
|
11
12
|
* @param args.status - Filter by status
|
|
12
13
|
* @param args.trigger_type - Filter by trigger type
|
|
13
|
-
* @param args.limit - Max results (default
|
|
14
|
+
* @param args.limit - Max results (optional, no default limit)
|
|
14
15
|
*/
|
|
15
16
|
export async function handleList(args, client) {
|
|
17
|
+
// Fetch all personas (client handles pagination)
|
|
16
18
|
let personas = await client.getPersonasForTenant();
|
|
19
|
+
const totalBeforeFilter = personas.length;
|
|
17
20
|
// Apply filters
|
|
18
21
|
if (args.query) {
|
|
19
22
|
const q = args.query.toLowerCase();
|
|
@@ -25,14 +28,16 @@ export async function handleList(args, client) {
|
|
|
25
28
|
if (args.trigger_type) {
|
|
26
29
|
personas = personas.filter(p => p.trigger_type === args.trigger_type);
|
|
27
30
|
}
|
|
28
|
-
// Apply limit
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
// Apply limit only if explicitly provided
|
|
32
|
+
if (args.limit && typeof args.limit === 'number') {
|
|
33
|
+
personas = personas.slice(0, args.limit);
|
|
34
|
+
}
|
|
31
35
|
// Get templates for type lookup (cached)
|
|
32
36
|
const templates = await getTemplates(client);
|
|
33
37
|
const templateMap = new Map(templates.map(t => [t.id, t]));
|
|
34
38
|
return {
|
|
35
39
|
count: personas.length,
|
|
40
|
+
total: totalBeforeFilter,
|
|
36
41
|
personas: personas.map(p => {
|
|
37
42
|
// Get type from template (dynamic, tenant-specific)
|
|
38
43
|
const template = templateMap.get(p.template_id ?? "");
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* routes to handleWorkflow BEFORE reaching this handler. See handlePersona()
|
|
13
13
|
* in handlers-consolidated.ts.
|
|
14
14
|
*/
|
|
15
|
+
import { PROJECT_TYPES } from "../../../sdk/knowledge.js";
|
|
15
16
|
import { resolvePersona, validateWidgetsForApi } from "../utils.js";
|
|
17
|
+
import { validateSearchDataSourceConsistency, validationToHandlerResult } from "../workflow/validation.js";
|
|
16
18
|
/**
|
|
17
19
|
* Apply WorkflowSpec changes to an existing workflow_def.
|
|
18
20
|
* This preserves action-specific inputs that compileWorkflow would lose.
|
|
@@ -200,7 +202,7 @@ export async function handleUpdate(args, client) {
|
|
|
200
202
|
: undefined;
|
|
201
203
|
// Determine persona type from projectSettings.projectType
|
|
202
204
|
const projectSettings = existingProtoConfig.projectSettings;
|
|
203
|
-
const isVoice = projectSettings?.projectType ===
|
|
205
|
+
const isVoice = projectSettings?.projectType === PROJECT_TYPES.voice;
|
|
204
206
|
// Build merged proto_config with smart widget-level merging
|
|
205
207
|
// Accept both 'config' (tool schema) and 'proto_config' (internal) for compatibility
|
|
206
208
|
let mergedProtoConfig = { ...existingProtoConfig };
|
|
@@ -310,6 +312,19 @@ export async function handleUpdate(args, client) {
|
|
|
310
312
|
// Use raw workflow_def if provided, otherwise keep existing
|
|
311
313
|
workflowToSet = args.workflow ?? existingWorkflow;
|
|
312
314
|
}
|
|
315
|
+
// ── VALIDATION: Use shared validation rules ──
|
|
316
|
+
// Run for any workflow update (workflow_spec or raw workflow)
|
|
317
|
+
if (workflowToSet) {
|
|
318
|
+
const validation = await validateSearchDataSourceConsistency(workflowToSet, persona.id, client);
|
|
319
|
+
if (!validation.valid) {
|
|
320
|
+
return validationToHandlerResult(validation, {
|
|
321
|
+
preview: preview,
|
|
322
|
+
persona_id: persona.id,
|
|
323
|
+
persona_name: persona.name,
|
|
324
|
+
changes: transformResult.changes || [],
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
313
328
|
// ── Preview mode - return without deploying ──
|
|
314
329
|
if (preview && workflowSpec) {
|
|
315
330
|
return {
|
|
@@ -317,6 +332,7 @@ export async function handleUpdate(args, client) {
|
|
|
317
332
|
persona_id: persona.id,
|
|
318
333
|
persona_name: persona.name,
|
|
319
334
|
persona_type: isVoice ? "voice" : "chat/dashboard",
|
|
335
|
+
validation_passed: true,
|
|
320
336
|
changes: transformResult.changes || [],
|
|
321
337
|
modified_workflow: workflowToSet,
|
|
322
338
|
hint: "Set preview=false to deploy these changes",
|
|
@@ -333,7 +349,13 @@ export async function handleUpdate(args, client) {
|
|
|
333
349
|
});
|
|
334
350
|
}
|
|
335
351
|
catch (apiErr) {
|
|
336
|
-
|
|
352
|
+
// Return structured error consistent with other handlers
|
|
353
|
+
const errorMessage = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
354
|
+
return {
|
|
355
|
+
error: `Update failed: ${errorMessage}`,
|
|
356
|
+
persona_id: persona.id,
|
|
357
|
+
persona_name: persona.name,
|
|
358
|
+
};
|
|
337
359
|
}
|
|
338
360
|
return {
|
|
339
361
|
success: true,
|
|
@@ -3,9 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Deploys a workflow_def to an existing persona.
|
|
5
5
|
* Includes deprecation warnings (does not block, just warns).
|
|
6
|
+
*
|
|
7
|
+
* BLOCKING VALIDATION:
|
|
8
|
+
* - Uses shared validation from validation.ts
|
|
6
9
|
*/
|
|
10
|
+
import { PROJECT_TYPES } from "../../../sdk/knowledge.js";
|
|
7
11
|
import { sanitizeWorkflowForDeploy } from "./utils.js";
|
|
8
12
|
import { DEPRECATED_ACTIONS_FALLBACK, checkWorkflowDeprecations } from "./index.js";
|
|
13
|
+
import { validateSearchDataSourceConsistency, validationToHandlerResult,
|
|
14
|
+
// Re-export for backwards compatibility
|
|
15
|
+
hasKnowledgeSearchNodes, extractActionTypeName, } from "./validation.js";
|
|
16
|
+
// Re-export for backwards compatibility with existing imports
|
|
17
|
+
export { hasKnowledgeSearchNodes, extractActionTypeName };
|
|
9
18
|
/**
|
|
10
19
|
* Handle workflow deploy mode
|
|
11
20
|
*/
|
|
@@ -23,8 +32,17 @@ export async function handleWorkflowDeploy(args, client) {
|
|
|
23
32
|
if (!persona) {
|
|
24
33
|
return { error: `Persona not found: ${personaId}` };
|
|
25
34
|
}
|
|
26
|
-
// Sanitize workflow
|
|
35
|
+
// Sanitize workflow FIRST so we validate what we'll actually deploy
|
|
27
36
|
const sanitizedWorkflow = sanitizeWorkflowForDeploy(workflowDef);
|
|
37
|
+
// ── BLOCKING VALIDATION: Use shared validation rules ──
|
|
38
|
+
const validation = await validateSearchDataSourceConsistency(sanitizedWorkflow, personaId, client);
|
|
39
|
+
if (!validation.valid) {
|
|
40
|
+
return validationToHandlerResult(validation, {
|
|
41
|
+
mode: "deploy",
|
|
42
|
+
persona_id: personaId,
|
|
43
|
+
persona_name: persona.name,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
28
46
|
// Check for deprecated actions (warn, don't block)
|
|
29
47
|
const deprecationWarnings = checkWorkflowDeprecations(workflowDef, DEPRECATED_ACTIONS_FALLBACK);
|
|
30
48
|
// Determine proto_config to use: provided > existing
|
|
@@ -33,7 +51,7 @@ export async function handleWorkflowDeploy(args, client) {
|
|
|
33
51
|
const protoConfigToUse = providedProtoConfig || existingProtoConfig || {};
|
|
34
52
|
// Check if this is a voice persona and warn if critical settings are missing
|
|
35
53
|
const projectSettings = protoConfigToUse.projectSettings;
|
|
36
|
-
const isVoice = projectSettings?.projectType ===
|
|
54
|
+
const isVoice = projectSettings?.projectType === PROJECT_TYPES.voice;
|
|
37
55
|
const warnings = [];
|
|
38
56
|
// Add deprecation warning message if deprecated actions found
|
|
39
57
|
if (deprecationWarnings.length > 0) {
|
|
@@ -15,6 +15,7 @@ import { runIntentArchitect } from "../../../sdk/intent-architect.js";
|
|
|
15
15
|
import { ensureActionRegistry } from "../../../sdk/action-registry.js";
|
|
16
16
|
import { ensureSchemaRegistry, validateWorkflowSpec, generateActionCatalogForLLM } from "../../../sdk/workflow-validator.js";
|
|
17
17
|
import { getTemplates, normalizeTriggerType } from "../../handlers/index.js";
|
|
18
|
+
import { validateSearchDataSourceConsistency, validationToHandlerResult } from "./validation.js";
|
|
18
19
|
/**
|
|
19
20
|
* Get persona widget context for workflow bindings
|
|
20
21
|
*/
|
|
@@ -141,7 +142,20 @@ async function deployToNewPersona(args, client, compiled, actionRegistry, getTem
|
|
|
141
142
|
...existingProtoConfig,
|
|
142
143
|
widgets: Array.from(widgetMap.values()),
|
|
143
144
|
};
|
|
144
|
-
// Step 4: Deploy workflow
|
|
145
|
+
// Step 4: Validate and Deploy workflow
|
|
146
|
+
// Validate search/data source consistency before deployment
|
|
147
|
+
const validation = await validateSearchDataSourceConsistency(compiled.workflow_def, newPersonaId, client);
|
|
148
|
+
if (!validation.valid) {
|
|
149
|
+
// Return validation error but keep persona (user can fix and retry)
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
personaId: newPersonaId,
|
|
153
|
+
personaName,
|
|
154
|
+
validationError: validation.error,
|
|
155
|
+
_fix: validation._fix,
|
|
156
|
+
workflowAttempted: compiled.workflow_def,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
145
159
|
// NOTE: The SDK's updateAiEmployee() handles workflowName namespace automatically.
|
|
146
160
|
// It will copy from existing workflow if present, or generate a valid namespace if not.
|
|
147
161
|
// It also fixes the results format. No need to manually manipulate these here.
|
|
@@ -293,6 +307,16 @@ export async function handleWorkflowGenerate(args, client, getTemplateId) {
|
|
|
293
307
|
if (!persona) {
|
|
294
308
|
return { error: `Persona not found: ${personaId}` };
|
|
295
309
|
}
|
|
310
|
+
// Validate search/data source consistency before deployment
|
|
311
|
+
const validation = await validateSearchDataSourceConsistency(compiled.workflow_def, personaId, client);
|
|
312
|
+
if (!validation.valid) {
|
|
313
|
+
return validationToHandlerResult(validation, {
|
|
314
|
+
mode: "generate",
|
|
315
|
+
persona_id: personaId,
|
|
316
|
+
persona_name: persona.name,
|
|
317
|
+
workflow_preview: compiled.workflow_def,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
296
320
|
await client.updateAiEmployee({
|
|
297
321
|
persona_id: personaId,
|
|
298
322
|
workflow: compiled.workflow_def,
|
|
@@ -303,7 +327,20 @@ export async function handleWorkflowGenerate(args, client, getTemplateId) {
|
|
|
303
327
|
else if (!preview && !personaId) {
|
|
304
328
|
// Greenfield: create new persona
|
|
305
329
|
const deployResult = await deployToNewPersona(args, client, compiled, actionRegistry, getTemplateId);
|
|
306
|
-
if (deployResult.
|
|
330
|
+
if (deployResult.validationError) {
|
|
331
|
+
// Validation failed - persona created but workflow not deployed
|
|
332
|
+
return {
|
|
333
|
+
error: deployResult.validationError,
|
|
334
|
+
_fix: deployResult._fix,
|
|
335
|
+
persona_id: deployResult.personaId,
|
|
336
|
+
persona_name: deployResult.personaName,
|
|
337
|
+
persona_created: true,
|
|
338
|
+
workflow_deployed: false,
|
|
339
|
+
workflow_attempted: deployResult.workflowAttempted,
|
|
340
|
+
hint: "Persona created, but workflow validation failed. Fix the issue and retry deployment.",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
else if (deployResult.workflowDeployError) {
|
|
307
344
|
result.workflow_deploy_error = deployResult.workflowDeployError;
|
|
308
345
|
result.workflow_attempted = deployResult.workflowAttempted;
|
|
309
346
|
result.status = "partial";
|
|
@@ -26,6 +26,7 @@ import { handleWorkflowOptimize } from "./optimize.js";
|
|
|
26
26
|
import { handleWorkflowDeploy } from "./deploy.js";
|
|
27
27
|
import { handleWorkflowGenerate } from "./generate.js";
|
|
28
28
|
import { handleWorkflowModify } from "./modify.js";
|
|
29
|
+
import { handleWorkflowValidate } from "./validate.js";
|
|
29
30
|
// Re-export for internal use (persona handler routes to these)
|
|
30
31
|
export { handleWorkflowAnalyze } from "./analyze.js";
|
|
31
32
|
export { handleWorkflowCompare } from "./compare.js";
|
|
@@ -240,6 +241,9 @@ export async function handleWorkflow(args, client, getTemplateId) {
|
|
|
240
241
|
if (mode === "deploy") {
|
|
241
242
|
return handleWorkflowDeploy(args, client);
|
|
242
243
|
}
|
|
244
|
+
if (mode === "validate") {
|
|
245
|
+
return handleWorkflowValidate(args, client);
|
|
246
|
+
}
|
|
243
247
|
// INTERNAL MODES (called from persona tool, not workflow tool)
|
|
244
248
|
if (mode === "modify" || mode === "extend") {
|
|
245
249
|
return handleWorkflowModify(args, client);
|
|
@@ -278,15 +282,15 @@ export async function handleWorkflow(args, client, getTemplateId) {
|
|
|
278
282
|
// Invalid mode
|
|
279
283
|
return {
|
|
280
284
|
error: `Invalid or missing mode: ${mode}`,
|
|
281
|
-
public_modes: ["get", "deploy"],
|
|
282
|
-
hint: "MCP provides data (get) and executes (deploy). LLM does all thinking.",
|
|
285
|
+
public_modes: ["get", "deploy", "validate"],
|
|
286
|
+
hint: "MCP provides data (get), validates (validate), and executes (deploy). LLM does all thinking.",
|
|
283
287
|
};
|
|
284
288
|
}
|
|
285
289
|
/**
|
|
286
290
|
* Check if a workflow mode has been extracted
|
|
287
291
|
*/
|
|
288
292
|
export function hasExtractedWorkflowHandler(mode) {
|
|
289
|
-
const extractedModes = ["get", "deploy", "modify", "extend", "generate", "analyze", "optimize", "compare"];
|
|
293
|
+
const extractedModes = ["get", "deploy", "validate", "modify", "extend", "generate", "analyze", "optimize", "compare"];
|
|
290
294
|
return extractedModes.includes(mode);
|
|
291
295
|
}
|
|
292
296
|
/**
|
|
@@ -296,6 +300,7 @@ export function getWorkflowModeHandler(mode) {
|
|
|
296
300
|
switch (mode) {
|
|
297
301
|
case "get":
|
|
298
302
|
case "deploy":
|
|
303
|
+
case "validate":
|
|
299
304
|
case "modify":
|
|
300
305
|
case "extend":
|
|
301
306
|
case "generate":
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { sanitizeWorkflowForDeploy } from "./utils.js";
|
|
12
12
|
import { ensureSchemaRegistry } from "../../../sdk/workflow-validator.js";
|
|
13
|
+
import { validateSearchDataSourceConsistency, validationToHandlerResult } from "./validation.js";
|
|
13
14
|
// ─────────────────────────────────────────────────────────────────────────
|
|
14
15
|
// Action Building Helpers (use ActionRegistry for metadata)
|
|
15
16
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -311,6 +312,8 @@ export async function handleWorkflowModify(args, client) {
|
|
|
311
312
|
const operations = args.operations;
|
|
312
313
|
const preview = Boolean(args.preview);
|
|
313
314
|
// If no structured operations provided, return guidance
|
|
315
|
+
// NOTE: This handler does NOT parse natural language - Agent must build operations
|
|
316
|
+
const userInput = args.input;
|
|
314
317
|
if (!operations || operations.length === 0) {
|
|
315
318
|
// Fetch persona for context
|
|
316
319
|
const persona = await client.getPersonaById(personaId);
|
|
@@ -328,9 +331,16 @@ export async function handleWorkflowModify(args, client) {
|
|
|
328
331
|
? schemaRegistry.getAllActions().map(a => ({ name: a.name, category: a.category, description: a.description }))
|
|
329
332
|
: [];
|
|
330
333
|
return {
|
|
331
|
-
message:
|
|
334
|
+
message: userInput
|
|
335
|
+
? `You provided input="${userInput}". This handler requires STRUCTURED operations, not natural language. Build operations from the context below.`
|
|
336
|
+
: "No operations provided. Returning workflow context for Agent to build operations.",
|
|
332
337
|
persona_id: personaId,
|
|
333
338
|
persona_name: persona.name,
|
|
339
|
+
// If user provided input, remind them to convert to operations
|
|
340
|
+
...(userInput && {
|
|
341
|
+
_warning: "IMPORTANT: The 'input' parameter is for providing context to the Agent, not for direct execution. The Agent must convert this into structured 'operations'.",
|
|
342
|
+
user_intent: userInput,
|
|
343
|
+
}),
|
|
334
344
|
// Current workflow state (DATA for Agent)
|
|
335
345
|
current_nodes: actions.map(a => ({
|
|
336
346
|
name: a.name,
|
|
@@ -341,10 +351,11 @@ export async function handleWorkflowModify(args, client) {
|
|
|
341
351
|
available_actions: availableActions.slice(0, 20), // Top 20 for context
|
|
342
352
|
// Guidance for Agent
|
|
343
353
|
_next_steps: [
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
"
|
|
354
|
+
userInput
|
|
355
|
+
? `1. Interpret the user intent: "${userInput}"`
|
|
356
|
+
: "1. Review current_nodes and available_actions above",
|
|
357
|
+
"2. Build ModificationOperation[] based on your analysis",
|
|
358
|
+
"3. Call again with operations=[{type:'insert'|'remove'|'rewire', ...}]",
|
|
348
359
|
],
|
|
349
360
|
// Example operation structures
|
|
350
361
|
example_operations: {
|
|
@@ -387,13 +398,25 @@ export async function handleWorkflowModify(args, client) {
|
|
|
387
398
|
const schemaRegistry = await ensureSchemaRegistry(client);
|
|
388
399
|
// Apply the structured operations
|
|
389
400
|
const result = applyWorkflowModifications(workflow, operations, schemaRegistry);
|
|
390
|
-
// Sanitize before deploy
|
|
401
|
+
// Sanitize before validation (validate what we'll actually deploy)
|
|
391
402
|
const sanitized = sanitizeWorkflowForDeploy(result.workflow);
|
|
403
|
+
// ── VALIDATION: Use shared validation rules ──
|
|
404
|
+
// Run BEFORE preview so user knows about issues early
|
|
405
|
+
const validation = await validateSearchDataSourceConsistency(sanitized, personaId, client);
|
|
406
|
+
if (!validation.valid) {
|
|
407
|
+
return validationToHandlerResult(validation, {
|
|
408
|
+
preview: preview,
|
|
409
|
+
persona_id: personaId,
|
|
410
|
+
persona_name: persona.name,
|
|
411
|
+
changes_attempted: result.changesApplied,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
392
414
|
if (preview) {
|
|
393
415
|
return {
|
|
394
416
|
preview: true,
|
|
395
417
|
persona_id: personaId,
|
|
396
418
|
persona_name: persona.name,
|
|
419
|
+
validation_passed: true, // Explicitly confirm validation passed
|
|
397
420
|
// What would change
|
|
398
421
|
changes_applied: result.changesApplied,
|
|
399
422
|
nodes_added: result.nodesAdded,
|
|
@@ -402,14 +425,18 @@ export async function handleWorkflowModify(args, client) {
|
|
|
402
425
|
connections_changed: result.connectionsChanged,
|
|
403
426
|
// The modified workflow (LLM should analyze with ema://rules/*)
|
|
404
427
|
modified_workflow: sanitized,
|
|
405
|
-
_tip: "Preview complete.
|
|
428
|
+
_tip: "Preview complete. Validation passed. Call without preview=true to deploy.",
|
|
406
429
|
};
|
|
407
430
|
}
|
|
408
431
|
// Deploy the workflow
|
|
432
|
+
// CRITICAL: Must include proto_config alongside workflow for changes to persist
|
|
433
|
+
// Always use the FULL existing proto_config from the persona
|
|
434
|
+
const existingProtoConfig = (persona.proto_config ?? {});
|
|
409
435
|
try {
|
|
410
436
|
await client.updateAiEmployee({
|
|
411
437
|
persona_id: personaId,
|
|
412
438
|
workflow: sanitized,
|
|
439
|
+
proto_config: existingProtoConfig,
|
|
413
440
|
});
|
|
414
441
|
return {
|
|
415
442
|
success: true,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Validation Handler
|
|
3
|
+
*
|
|
4
|
+
* Implements static validation using path enumeration.
|
|
5
|
+
* Validates: required outputs, multiple writers, response/abstain, category hierarchy.
|
|
6
|
+
*/
|
|
7
|
+
import { validateWorkflowStatic } from "../../../sdk/workflow-static-validator.js";
|
|
8
|
+
/**
|
|
9
|
+
* Handle workflow(mode="validate") - static validation
|
|
10
|
+
*
|
|
11
|
+
* Validates workflow using path enumeration:
|
|
12
|
+
* - Enumerates all execution paths
|
|
13
|
+
* - Checks each path for named results compliance
|
|
14
|
+
* - Returns enhanced error messages (what, why, how)
|
|
15
|
+
*/
|
|
16
|
+
export async function handleWorkflowValidate(args, client) {
|
|
17
|
+
const personaId = args.persona_id;
|
|
18
|
+
const workflowDef = args.workflow_def;
|
|
19
|
+
const workflowSpec = args.workflow_spec;
|
|
20
|
+
const validationType = args.validation_type || "all";
|
|
21
|
+
// Get workflow to validate
|
|
22
|
+
let spec = null;
|
|
23
|
+
if (workflowSpec) {
|
|
24
|
+
// Use provided workflow spec
|
|
25
|
+
spec = workflowSpec;
|
|
26
|
+
}
|
|
27
|
+
else if (workflowDef) {
|
|
28
|
+
// Convert workflow_def to WorkflowSpec
|
|
29
|
+
// TODO: Implement conversion
|
|
30
|
+
return {
|
|
31
|
+
error: "workflow_def validation not yet implemented. Use workflow_spec instead.",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
else if (personaId) {
|
|
35
|
+
// Get workflow from persona
|
|
36
|
+
const persona = await client.getPersonaById(personaId);
|
|
37
|
+
if (!persona) {
|
|
38
|
+
return { error: `Persona not found: ${personaId}` };
|
|
39
|
+
}
|
|
40
|
+
const personaWorkflowDef = persona.workflow_def;
|
|
41
|
+
if (!personaWorkflowDef) {
|
|
42
|
+
return {
|
|
43
|
+
error: "Persona has no workflow to validate",
|
|
44
|
+
_tip: "Deploy a workflow first, then validate it",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// TODO: Convert workflow_def to WorkflowSpec
|
|
48
|
+
return {
|
|
49
|
+
error: "workflow_def validation not yet implemented. Use workflow_spec instead.",
|
|
50
|
+
_tip: "Convert workflow_def to WorkflowSpec format for validation",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
return {
|
|
55
|
+
error: "Either persona_id, workflow_def, or workflow_spec required",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (!spec) {
|
|
59
|
+
return { error: "Could not extract workflow spec for validation" };
|
|
60
|
+
}
|
|
61
|
+
// Perform static validation
|
|
62
|
+
const validationResult = validateWorkflowStatic(spec, {
|
|
63
|
+
max_paths: args.max_paths || 1000,
|
|
64
|
+
timeout_ms: args.timeout_ms || 100,
|
|
65
|
+
});
|
|
66
|
+
// Format response
|
|
67
|
+
return {
|
|
68
|
+
valid: validationResult.valid,
|
|
69
|
+
errors: validationResult.errors,
|
|
70
|
+
warnings: validationResult.warnings,
|
|
71
|
+
info: validationResult.info,
|
|
72
|
+
summary: validationResult.summary,
|
|
73
|
+
_tip: validationResult.valid
|
|
74
|
+
? "Workflow passed static validation. Ready to deploy."
|
|
75
|
+
: "Fix errors before deploying. Use error messages (what/why/how) to guide fixes.",
|
|
76
|
+
_next_step: validationResult.valid
|
|
77
|
+
? "Deploy with: workflow(mode='deploy', persona_id='...', workflow_def={...})"
|
|
78
|
+
: [
|
|
79
|
+
"Review errors above",
|
|
80
|
+
"Fix issues using 'how_to_fix' guidance",
|
|
81
|
+
"Re-validate: workflow(mode='validate', ...)",
|
|
82
|
+
"Deploy when valid: workflow(mode='deploy', ...)",
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Validation Rules
|
|
3
|
+
*
|
|
4
|
+
* Shared validation logic for all workflow deployment paths.
|
|
5
|
+
* Called by: deploy.ts, modify.ts, update.ts (persona)
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Extract action type name from various workflow_def formats.
|
|
9
|
+
* Handles both string and object formats:
|
|
10
|
+
* - String: "search/v2"
|
|
11
|
+
* - Object: { name: { name: "search", namespaces: [...] }, version: "v2" }
|
|
12
|
+
*/
|
|
13
|
+
export function extractActionTypeName(action) {
|
|
14
|
+
if (typeof action === "string") {
|
|
15
|
+
return action;
|
|
16
|
+
}
|
|
17
|
+
if (typeof action === "object" && action !== null) {
|
|
18
|
+
const actionObj = action;
|
|
19
|
+
// Format: { name: { name: "search", namespaces: [...] } }
|
|
20
|
+
if (actionObj.name && typeof actionObj.name === "object") {
|
|
21
|
+
const nameObj = actionObj.name;
|
|
22
|
+
if (typeof nameObj.name === "string") {
|
|
23
|
+
return nameObj.name;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Format: { name: "search" } (direct)
|
|
27
|
+
if (typeof actionObj.name === "string") {
|
|
28
|
+
return actionObj.name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Detect knowledge search nodes in a workflow.
|
|
35
|
+
* Returns true if workflow contains search/v2 or search_datastore nodes (NOT web_search).
|
|
36
|
+
*/
|
|
37
|
+
export function hasKnowledgeSearchNodes(workflowDef) {
|
|
38
|
+
const actions = (workflowDef.actions ?? []);
|
|
39
|
+
return actions.some(a => {
|
|
40
|
+
// Extract action type from various formats
|
|
41
|
+
const actionTypeName = extractActionTypeName(a.action) || a.actionType || "";
|
|
42
|
+
const nodeName = (typeof a.name === "string" ? a.name : "").toLowerCase();
|
|
43
|
+
// Match: search, search/v2, search_datastore
|
|
44
|
+
// Exclude: web_search, live_web_search
|
|
45
|
+
const isSearchNode = actionTypeName.includes("search") ||
|
|
46
|
+
nodeName.includes("search");
|
|
47
|
+
const isWebSearch = actionTypeName.includes("web_search") ||
|
|
48
|
+
nodeName.includes("web_search");
|
|
49
|
+
return isSearchNode && !isWebSearch;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validate workflow and data source consistency.
|
|
54
|
+
*
|
|
55
|
+
* Rules:
|
|
56
|
+
* 1. If workflow has search nodes → data sources MUST be indexed
|
|
57
|
+
* 2. If persona has indexed data sources → workflow MUST have search nodes
|
|
58
|
+
*
|
|
59
|
+
* @param workflowDef - The workflow to validate
|
|
60
|
+
* @param personaId - The persona ID for data source lookup
|
|
61
|
+
* @param client - EmaClient for API calls
|
|
62
|
+
* @returns Validation result with error details if invalid
|
|
63
|
+
*/
|
|
64
|
+
export async function validateSearchDataSourceConsistency(workflowDef, personaId, client) {
|
|
65
|
+
const hasSearchNodes = hasKnowledgeSearchNodes(workflowDef);
|
|
66
|
+
// Try to get data source stats
|
|
67
|
+
let dataSourceStats = null;
|
|
68
|
+
try {
|
|
69
|
+
dataSourceStats = await client.getDataSourceAggregates(personaId);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// If we can't check stats, log warning but don't block
|
|
73
|
+
console.error(`[validation] Could not verify data sources for ${personaId}`);
|
|
74
|
+
}
|
|
75
|
+
// If we couldn't get stats, allow through (don't block on API errors)
|
|
76
|
+
if (!dataSourceStats) {
|
|
77
|
+
return { valid: true };
|
|
78
|
+
}
|
|
79
|
+
// RULE 1: If workflow has search nodes, data sources MUST be indexed
|
|
80
|
+
if (hasSearchNodes) {
|
|
81
|
+
if (dataSourceStats.total === 0) {
|
|
82
|
+
return {
|
|
83
|
+
valid: false,
|
|
84
|
+
error: "DEPLOYMENT BLOCKED: Workflow contains knowledge search nodes but persona has no data sources attached.",
|
|
85
|
+
validation_failed: "search_nodes_require_data_sources",
|
|
86
|
+
current_state: {
|
|
87
|
+
search_nodes_detected: true,
|
|
88
|
+
data_sources_total: 0,
|
|
89
|
+
data_sources_indexed: 0,
|
|
90
|
+
},
|
|
91
|
+
_fix: [
|
|
92
|
+
"1. Upload documents: persona(id='...',data={method:'upload',path:'/path/to/doc.pdf'})",
|
|
93
|
+
"2. Check status: persona(id='...',data={method:'stats'}) → wait for 'success' > 0",
|
|
94
|
+
"3. Then retry the deployment",
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (dataSourceStats.success === 0) {
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
error: "DEPLOYMENT BLOCKED: Workflow contains knowledge search nodes but no data sources have been successfully indexed.",
|
|
102
|
+
validation_failed: "search_nodes_require_indexed_data",
|
|
103
|
+
current_state: {
|
|
104
|
+
search_nodes_detected: true,
|
|
105
|
+
data_sources_total: dataSourceStats.total,
|
|
106
|
+
data_sources_pending: dataSourceStats.pending,
|
|
107
|
+
data_sources_indexed: dataSourceStats.success,
|
|
108
|
+
data_sources_failed: dataSourceStats.failed,
|
|
109
|
+
},
|
|
110
|
+
_fix: dataSourceStats.pending > 0
|
|
111
|
+
? [
|
|
112
|
+
"Data sources are still indexing. Wait for indexing to complete:",
|
|
113
|
+
"1. Check status: persona(id='...',data={method:'stats'})",
|
|
114
|
+
"2. Retry when 'success' > 0",
|
|
115
|
+
]
|
|
116
|
+
: [
|
|
117
|
+
"All data sources failed indexing. Check file formats and re-upload:",
|
|
118
|
+
"1. List files: persona(id='...',data={method:'list'})",
|
|
119
|
+
"2. Delete failed: persona(id='...',data={method:'delete',file_id:'...'})",
|
|
120
|
+
"3. Re-upload: persona(id='...',data={method:'upload',path:'...'})",
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// RULE 2: If persona has indexed data sources, workflow MUST have search nodes
|
|
126
|
+
if (!hasSearchNodes && dataSourceStats.success > 0) {
|
|
127
|
+
return {
|
|
128
|
+
valid: false,
|
|
129
|
+
error: "DEPLOYMENT BLOCKED: Persona has indexed knowledge files but workflow has no search nodes. The uploaded documents will NEVER be used.",
|
|
130
|
+
validation_failed: "data_sources_require_search_nodes",
|
|
131
|
+
current_state: {
|
|
132
|
+
search_nodes_detected: false,
|
|
133
|
+
data_sources_total: dataSourceStats.total,
|
|
134
|
+
data_sources_indexed: dataSourceStats.success,
|
|
135
|
+
},
|
|
136
|
+
_fix: [
|
|
137
|
+
"Your workflow needs a search node to query the uploaded documents.",
|
|
138
|
+
"1. Get workflow schema: workflow(mode='get', persona_id='...')",
|
|
139
|
+
"2. Add search node: Update workflow_def to include a search/v2 node",
|
|
140
|
+
"3. Example pattern: trigger → summarizer → search/v2 → respond → output",
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { valid: true };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Convert validation result to HandlerResult format for returning errors.
|
|
148
|
+
*/
|
|
149
|
+
export function validationToHandlerResult(validation, additionalContext) {
|
|
150
|
+
if (validation.valid) {
|
|
151
|
+
throw new Error("validationToHandlerResult called on valid result");
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
error: validation.error,
|
|
155
|
+
validation_failed: validation.validation_failed,
|
|
156
|
+
current_state: validation.current_state,
|
|
157
|
+
_fix: validation._fix,
|
|
158
|
+
...additionalContext,
|
|
159
|
+
};
|
|
160
|
+
}
|