@ema.co/mcp-toolkit 2026.3.24 → 2026.3.25-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.
@@ -16,7 +16,9 @@
16
16
  // ─────────────────────────────────────────────────────────────────────────────
17
17
  const DEFAULT_TTL_MS = 3_600_000; // 1 hour
18
18
  const SEARCH_LIMIT = 200;
19
- const SOURCE_FILTER = "mcp-toolkit";
19
+ // Accept guidance from both MCP toolkit extractors and knowledge-adapters pipeline.
20
+ // MCP owns raw TypeScript definitions; knowledge-adapters handles extraction + publishing.
21
+ const SOURCE_FILTERS = ["mcp-toolkit", "repo-knowledge"];
20
22
  // ─────────────────────────────────────────────────────────────────────────────
21
23
  // Implementation
22
24
  // ─────────────────────────────────────────────────────────────────────────────
@@ -29,6 +31,8 @@ export class GuidanceCache {
29
31
  topics = [];
30
32
  patterns = [];
31
33
  questions = [];
34
+ invariants = [];
35
+ contextualGuidanceMap = new Map();
32
36
  warmedAt = null;
33
37
  constructor(searchFn, config) {
34
38
  this.searchFn = searchFn;
@@ -42,8 +46,8 @@ export class GuidanceCache {
42
46
  */
43
47
  async warm() {
44
48
  try {
45
- const results = await this.searchFn(`source:${SOURCE_FILTER}`, {
46
- filters: { source: [SOURCE_FILTER] },
49
+ const results = await this.searchFn(`source:${SOURCE_FILTERS[0]}`, {
50
+ filters: { source: SOURCE_FILTERS },
47
51
  limit: SEARCH_LIMIT,
48
52
  });
49
53
  this.index(results);
@@ -114,6 +118,37 @@ export class GuidanceCache {
114
118
  this.maybeRefresh();
115
119
  return this.questions;
116
120
  }
121
+ /** All structural invariants (graph, branching, response, type rules). */
122
+ getInvariants() {
123
+ this.maybeRefresh();
124
+ return this.invariants;
125
+ }
126
+ /**
127
+ * Get contextual guidance for a specific key.
128
+ * Keys follow the pattern: "{tool}.{method}.{result_shape}"
129
+ * Also checks wildcards: "*.{method}.{shape}", "{tool}.*.{shape}", "*.*.{shape}"
130
+ */
131
+ getContextualGuidance(tool, method, shape) {
132
+ this.maybeRefresh();
133
+ // Try exact match first, then progressively broader wildcards
134
+ const candidates = [
135
+ `${tool}.${method}.${shape}`,
136
+ `${tool}.*.${shape}`,
137
+ `*.${method}.${shape}`,
138
+ `*.*.${shape}`,
139
+ ];
140
+ for (const key of candidates) {
141
+ const atom = this.contextualGuidanceMap.get(key);
142
+ if (atom)
143
+ return atom;
144
+ }
145
+ return undefined;
146
+ }
147
+ /** All contextual guidance atoms (for debugging/inspection). */
148
+ getAllContextualGuidance() {
149
+ this.maybeRefresh();
150
+ return Array.from(this.contextualGuidanceMap.values());
151
+ }
117
152
  // ── Internals ───────────────────────────────────────────────────────────
118
153
  clear() {
119
154
  this.rules = [];
@@ -122,6 +157,8 @@ export class GuidanceCache {
122
157
  this.topics = [];
123
158
  this.patterns = [];
124
159
  this.questions = [];
160
+ this.invariants = [];
161
+ this.contextualGuidanceMap = new Map();
125
162
  }
126
163
  /**
127
164
  * Index artifacts by reading structured data from structData.data.
@@ -172,6 +209,16 @@ export class GuidanceCache {
172
209
  case "qualifying-question":
173
210
  this.questions.push(data);
174
211
  break;
212
+ case "structural-invariant":
213
+ this.invariants.push(data);
214
+ break;
215
+ case "contextual-guidance": {
216
+ const atom = data;
217
+ const key = atom.key
218
+ ?? `${atom.tool ?? "*"}.${atom.method ?? "*"}.${atom.result_shape ?? "*"}`;
219
+ this.contextualGuidanceMap.set(key, { ...atom, key });
220
+ break;
221
+ }
175
222
  default:
176
223
  break;
177
224
  }
@@ -195,8 +242,18 @@ export class GuidanceCache {
195
242
  return "workflow-pattern";
196
243
  if (tags.includes("qualifying-question"))
197
244
  return "qualifying-question";
245
+ if (tags.includes("structural-invariant"))
246
+ return "structural-invariant";
247
+ if (tags.includes("structural-invariant"))
248
+ return "structural-invariant";
249
+ if (tags.includes("contextual-guidance"))
250
+ return "contextual-guidance";
198
251
  if (name.includes("tool-guidance"))
199
252
  return "tool-guidance";
253
+ if (name.startsWith("invariant-"))
254
+ return "structural-invariant";
255
+ if (name.includes("contextual-guidance"))
256
+ return "contextual-guidance";
200
257
  return undefined;
201
258
  }
202
259
  }
@@ -15,6 +15,10 @@ export const FILE_TO_SOURCE = {
15
15
  "src/config/workflow-patterns.ts": ["workflow-patterns"],
16
16
  "src/config/guidance-rules.ts": ["guidance-rules"],
17
17
  "src/mcp/knowledge-guidance-topics.ts": ["guidance-topics"],
18
+ // Upstream dependency: workflow-engine/pkg/engine/validator.go
19
+ // When the Go validator changes, structural-rules.ts should be updated to match.
20
+ // The knowledge-adapters pipeline (mcp-toolkit adapter) re-extracts and publishes
21
+ // to DE, where GuidanceCache auto-refreshes within 1hr TTL.
18
22
  "src/mcp/domain/structural-rules.ts": ["structural-invariants"],
19
23
  "src/mcp/domain/validation-rules.ts": ["validation-rules"],
20
24
  "src/sdk/generated/deprecated-actions.ts": ["deprecated-actions"],
@@ -165,6 +165,46 @@ const QUERY_SIGNALS = [
165
165
  ],
166
166
  },
167
167
  ];
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // Dynamic Preamble — context-aware system instructions for :answer endpoint
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+ /** Base preamble applied to all answer queries. */
172
+ const BASE_PREAMBLE = [
173
+ "You are answering questions about the Ema AI Employee platform — a workflow orchestration system.",
174
+ "When referencing actions or nodes, use their exact actionType names (e.g., chat_categorizer, respond_with_sources).",
175
+ "When showing fixes, include concrete workflow_def JSON snippets when available in the source documents.",
176
+ "Cite specific document IDs when available.",
177
+ ].join(" ");
178
+ /** Query-pattern-specific preamble additions. */
179
+ const PREAMBLE_SIGNALS = [
180
+ {
181
+ pattern: /\b(fix|error|fail|broken|wrong|issue|bug|debug)\b/i,
182
+ addition: "Focus on the Fix field from structural invariants. Show the violation, then the fix.",
183
+ },
184
+ {
185
+ pattern: /\b(how to|how do|pattern|example|wire|connect|build)\b/i,
186
+ addition: "Provide step-by-step instructions with workflow_def JSON examples where available.",
187
+ },
188
+ {
189
+ pattern: /\b(rule|constraint|invariant|must|require|validate)\b/i,
190
+ addition: "List the relevant rules with their severity (critical/warning) and enforcement status.",
191
+ },
192
+ ];
193
+ /**
194
+ * Build a context-aware preamble for the :answer endpoint.
195
+ * Returns undefined if preamble should be omitted (keeps DE default).
196
+ */
197
+ function buildDefaultPreamble(query) {
198
+ // Skip preamble for very short or exact-match queries (action name lookups)
199
+ if (query.length < 10 || !query.includes(" "))
200
+ return undefined;
201
+ const additions = PREAMBLE_SIGNALS
202
+ .filter(s => s.pattern.test(query))
203
+ .map(s => s.addition);
204
+ return additions.length > 0
205
+ ? `${BASE_PREAMBLE} ${additions.join(" ")}`
206
+ : BASE_PREAMBLE;
207
+ }
168
208
  /**
169
209
  * Build per-query boostSpec from signal matching.
170
210
  * Returns undefined if no signals match or if the caller already filtered
@@ -265,12 +305,14 @@ function transformDeResponse(deResponse, mode) {
265
305
  * Falls back gracefully — returns results-only if answer generation fails.
266
306
  */
267
307
  async function answerDirect(query, options) {
268
- const { filters, topK = 10, elevate = false } = options;
308
+ const { filters, topK = 10, elevate = false, preamble } = options;
269
309
  const de = getDeConfig();
270
310
  const headers = buildDeHeaders();
271
311
  if (!headers)
272
312
  return { results: [], mode: "answer" };
273
313
  const filter = buildFilterExpression(filters, elevate);
314
+ // Dynamic preamble: caller-provided or auto-generated from query context
315
+ const effectivePreamble = preamble ?? buildDefaultPreamble(query);
274
316
  const body = {
275
317
  query: { text: query },
276
318
  relatedQuestionsSpec: { enable: true },
@@ -280,6 +322,7 @@ async function answerDirect(query, options) {
280
322
  ignoreLowRelevantContent: true,
281
323
  includeCitations: true,
282
324
  modelSpec: { modelVersion: "stable" },
325
+ ...(effectivePreamble ? { promptSpec: { preamble: effectivePreamble } } : {}),
283
326
  },
284
327
  searchSpec: {
285
328
  searchParams: {
@@ -529,6 +572,7 @@ export async function searchDocuments(query, options = {}) {
529
572
  filters: options.filters,
530
573
  topK: options.topK,
531
574
  elevate: options.elevate,
575
+ preamble: options.preamble,
532
576
  });
533
577
  if (answerResult.generativeAnswer || answerResult.results.length > 0)
534
578
  return answerResult;
@@ -550,6 +594,50 @@ export async function searchDocuments(query, options = {}) {
550
594
  return result;
551
595
  }
552
596
  // ─────────────────────────────────────────────────────────────────────────────
597
+ // Browse — list/filter documents without a search query
598
+ // ─────────────────────────────────────────────────────────────────────────────
599
+ /**
600
+ * Browse DE documents without a search query.
601
+ * Uses the :search endpoint with an empty query — same endpoint, filter-only mode.
602
+ * Useful for listing all documents of a type, source, or tag.
603
+ *
604
+ * @see https://docs.cloud.google.com/generative-ai-app-builder/docs/browse-generic-search
605
+ */
606
+ export async function browseDocuments(options = {}) {
607
+ const { filters, elevate = false, pageSize = 50, orderBy } = options;
608
+ const de = getDeConfig();
609
+ const headers = buildDeHeaders();
610
+ if (!headers)
611
+ return { results: [], mode: "raw" };
612
+ await ensureGcpAuth();
613
+ const filter = buildFilterExpression(filters, elevate);
614
+ const body = {
615
+ query: "",
616
+ pageSize,
617
+ ...(filter ? { filter } : {}),
618
+ ...(orderBy ? { orderBy } : {}),
619
+ contentSearchSpec: {
620
+ snippetSpec: { returnSnippet: false },
621
+ },
622
+ };
623
+ const url = `${de.baseUrl}/${de.servingConfig}:search`;
624
+ try {
625
+ const resp = await fetch(url, {
626
+ method: "POST",
627
+ headers,
628
+ body: JSON.stringify(body),
629
+ signal: AbortSignal.timeout(DE_TIMEOUT_MS),
630
+ });
631
+ if (!resp.ok)
632
+ return { results: [], mode: "raw" };
633
+ const data = await resp.json();
634
+ return transformDeResponse(data, "raw");
635
+ }
636
+ catch {
637
+ return { results: [], mode: "raw" };
638
+ }
639
+ }
640
+ // ─────────────────────────────────────────────────────────────────────────────
553
641
  // User Event Tracking
554
642
  // ─────────────────────────────────────────────────────────────────────────────
555
643
  export async function writeUserEvent(event) {
@@ -1,51 +1,227 @@
1
1
  /**
2
2
  * Generation Schema
3
3
  *
4
- * Transforms AGENT_CATALOG into a compact format for workflow generation.
5
- * This avoids the token waste of including full documentation in prompts.
4
+ * API-first schema generation: fetches live action definitions from the
5
+ * backend, enriches with catalog metadata, falls back to static catalog
6
+ * when the API is unavailable.
7
+ *
8
+ * This ensures agents always see the FULL set of required inputs/outputs
9
+ * as defined by the backend, not a hand-curated subset from documentation.
6
10
  *
7
11
  * The auto-builder sends ~70K tokens of documentation per request.
8
12
  * This schema reduces that to ~5K tokens of actionable constraints.
9
13
  */
10
14
  import { AGENT_CATALOG } from "../knowledge.js";
11
15
  import { INPUT_SOURCE_RULES } from "./validation-rules.js";
16
+ import { STRUCTURAL_INVARIANTS } from "./structural-rules.js";
12
17
  import { LLM_ACTIONS, LLM_WIDGET_INPUTS, widgetBinding } from "../../config/widget-bindings.js";
13
- // ─────────────────────────────────────────────────────────────────────────────
14
- // Schema Generation
15
- // ─────────────────────────────────────────────────────────────────────────────
18
+ import { searchDocuments } from "../../knowledge/search-client.js";
19
+ export async function buildCompactAgents(client) {
20
+ // Build catalog index for enrichment
21
+ const catalogIndex = new Map();
22
+ for (const agent of AGENT_CATALOG) {
23
+ catalogIndex.set(agent.actionName, agent);
24
+ }
25
+ // Try API-first for full input/output definitions
26
+ if (client) {
27
+ try {
28
+ const apiAgents = await buildFromApi(client, catalogIndex);
29
+ if (Object.keys(apiAgents).length > 0) {
30
+ return { agents: apiAgents, source: "api" };
31
+ }
32
+ }
33
+ catch (e) {
34
+ console.error(`[generation-schema] API listActions failed: ${e instanceof Error ? e.message : String(e)}`);
35
+ }
36
+ }
37
+ // DE middle layer: search for action definitions in Discovery Engine
38
+ try {
39
+ const deAgents = await buildFromDE(catalogIndex);
40
+ if (deAgents && Object.keys(deAgents).length > 0) {
41
+ return { agents: deAgents, source: "api" };
42
+ }
43
+ }
44
+ catch (e) {
45
+ console.error(`[generation-schema] DE search failed, using static catalog: ${e instanceof Error ? e.message : String(e)}`);
46
+ }
47
+ // Final fallback: static catalog (may have incomplete inputs)
48
+ return { agents: buildFromCatalog(catalogIndex), source: "catalog" };
49
+ }
50
+ /** Synchronous version for backwards compatibility (uses catalog only). */
51
+ export function buildCompactAgentsSync() {
52
+ const catalogIndex = new Map();
53
+ for (const agent of AGENT_CATALOG) {
54
+ catalogIndex.set(agent.actionName, agent);
55
+ }
56
+ return { agents: buildFromCatalog(catalogIndex), source: "catalog" };
57
+ }
16
58
  /**
17
- * Build a compact agent lookup from the full catalog
59
+ * Build agents from live API full input/output definitions.
60
+ * Enriches with catalog metadata (description, whenToUse, etc.).
18
61
  */
19
- export function buildCompactAgents() {
62
+ async function buildFromApi(client, catalogIndex) {
63
+ const actionDTOs = await client.listActions();
20
64
  const agents = {};
21
- for (const agent of AGENT_CATALOG) {
65
+ for (const dto of actionDTOs) {
66
+ const typeName = dto.typeName;
67
+ const name = typeName?.name?.name;
68
+ if (!name)
69
+ continue;
22
70
  const inputs = {};
23
- for (const input of agent.inputs ?? []) {
24
- inputs[input.name] = { type: input.type, required: input.required };
71
+ const outputs = {};
72
+ // Parse inputs from API response (FULL definition)
73
+ const inputsObj = dto.inputs?.inputs;
74
+ if (inputsObj && typeof inputsObj === "object") {
75
+ for (const [inputName, inputDef] of Object.entries(inputsObj)) {
76
+ const def = inputDef;
77
+ inputs[inputName] = {
78
+ type: (def.type?.wellKnownType ?? "WELL_KNOWN_TYPE_ANY"),
79
+ required: !def.isOptional,
80
+ };
81
+ }
82
+ }
83
+ // Parse outputs from API response
84
+ const outputsObj = dto.outputs?.outputs;
85
+ if (outputsObj && typeof outputsObj === "object") {
86
+ for (const [outputName, outputDef] of Object.entries(outputsObj)) {
87
+ const def = outputDef;
88
+ outputs[outputName] = (def.type?.wellKnownType ?? "WELL_KNOWN_TYPE_ANY");
89
+ }
90
+ }
91
+ // Inject widget inputs for LLM actions (may already be in API response)
92
+ if (LLM_ACTIONS.has(name)) {
93
+ for (const binding of LLM_WIDGET_INPUTS) {
94
+ if (!inputs[binding.inputName]) {
95
+ inputs[binding.inputName] = {
96
+ type: "WELL_KNOWN_TYPE_ANY",
97
+ required: binding.required,
98
+ };
99
+ }
100
+ }
25
101
  }
102
+ // Enrich with catalog metadata (doesn't override inputs/outputs)
103
+ const catalogEntry = catalogIndex.get(name);
104
+ agents[name] = {
105
+ name: catalogEntry?.displayName ?? name,
106
+ category: catalogEntry?.category ?? "unknown",
107
+ description: catalogEntry?.description,
108
+ whenToUse: catalogEntry?.whenToUse,
109
+ aliases: catalogEntry?.aliases,
110
+ tier: catalogEntry?.tier,
111
+ inputs,
112
+ outputs,
113
+ constraints: catalogEntry?.criticalRules,
114
+ };
115
+ }
116
+ return agents;
117
+ }
118
+ /**
119
+ * Build agents from DE search — middle layer between API and static catalog.
120
+ * Queries DE for entity-type action docs, matches to catalog by action name
121
+ * extracted from the document ID (e.g., "entity:search_v2" → "search_v2").
122
+ * Returns null if DE search yields no action entities.
123
+ */
124
+ async function buildFromDE(catalogIndex) {
125
+ const response = await searchDocuments("action definitions inputs outputs workflow actions", {
126
+ topK: 15,
127
+ filters: { type: ["entity"] },
128
+ });
129
+ if (!response.results || response.results.length === 0)
130
+ return null;
131
+ const deAgents = {};
132
+ const deActionNames = new Set();
133
+ for (const result of response.results) {
134
+ const doc = result.document;
135
+ if (!doc?.id)
136
+ continue;
137
+ // Extract action name from entity ID (e.g., "entity:search_v2" → "search_v2")
138
+ const actionName = doc.id.startsWith("entity:") ? doc.id.slice("entity:".length) : "";
139
+ if (!actionName)
140
+ continue;
141
+ deActionNames.add(actionName);
142
+ const catalogEntry = catalogIndex.get(actionName);
143
+ const inputs = {};
26
144
  const outputs = {};
27
- for (const output of agent.outputs ?? []) {
28
- outputs[output.name] = output.type;
145
+ // Use catalog I/O specs (DE entities don't carry structured I/O)
146
+ if (catalogEntry) {
147
+ for (const input of catalogEntry.inputs ?? []) {
148
+ inputs[input.name] = { type: input.type, required: input.required };
149
+ }
150
+ for (const output of catalogEntry.outputs ?? []) {
151
+ outputs[output.name] = output.type;
152
+ }
29
153
  }
30
- if (LLM_ACTIONS.has(agent.actionName)) {
154
+ if (LLM_ACTIONS.has(actionName)) {
31
155
  for (const binding of LLM_WIDGET_INPUTS) {
156
+ if (!inputs[binding.inputName]) {
157
+ inputs[binding.inputName] = {
158
+ type: "WELL_KNOWN_TYPE_ANY",
159
+ required: binding.required,
160
+ };
161
+ }
162
+ }
163
+ }
164
+ deAgents[actionName] = {
165
+ name: catalogEntry?.displayName ?? doc.name ?? actionName,
166
+ category: catalogEntry?.category ?? "unknown",
167
+ description: catalogEntry?.description ?? doc.description ?? doc.summary,
168
+ whenToUse: catalogEntry?.whenToUse,
169
+ aliases: catalogEntry?.aliases,
170
+ tier: catalogEntry?.tier,
171
+ inputs,
172
+ outputs,
173
+ constraints: catalogEntry?.criticalRules,
174
+ };
175
+ }
176
+ if (deActionNames.size === 0)
177
+ return null;
178
+ // Supplement with catalog entries not found in DE
179
+ for (const [actionName, catalogEntry] of catalogIndex) {
180
+ if (deAgents[actionName])
181
+ continue;
182
+ deAgents[actionName] = buildCatalogAgent(actionName, catalogEntry);
183
+ }
184
+ return deAgents;
185
+ }
186
+ /** Build a CompactAgent from a single catalog entry. */
187
+ function buildCatalogAgent(actionName, entry) {
188
+ const inputs = {};
189
+ for (const input of entry.inputs ?? []) {
190
+ inputs[input.name] = { type: input.type, required: input.required };
191
+ }
192
+ const outputs = {};
193
+ for (const output of entry.outputs ?? []) {
194
+ outputs[output.name] = output.type;
195
+ }
196
+ if (LLM_ACTIONS.has(actionName)) {
197
+ for (const binding of LLM_WIDGET_INPUTS) {
198
+ if (!inputs[binding.inputName]) {
32
199
  inputs[binding.inputName] = {
33
200
  type: "WELL_KNOWN_TYPE_ANY",
34
201
  required: binding.required,
35
202
  };
36
203
  }
37
204
  }
38
- agents[agent.actionName] = {
39
- name: agent.displayName,
40
- category: agent.category,
41
- description: agent.description,
42
- whenToUse: agent.whenToUse,
43
- aliases: agent.aliases,
44
- tier: agent.tier,
45
- inputs,
46
- outputs,
47
- constraints: agent.criticalRules,
48
- };
205
+ }
206
+ return {
207
+ name: entry.displayName,
208
+ category: entry.category,
209
+ description: entry.description,
210
+ whenToUse: entry.whenToUse,
211
+ aliases: entry.aliases,
212
+ tier: entry.tier,
213
+ inputs,
214
+ outputs,
215
+ constraints: entry.criticalRules,
216
+ };
217
+ }
218
+ /**
219
+ * Build agents from static AGENT_CATALOG (fallback when API unavailable).
220
+ */
221
+ function buildFromCatalog(catalogIndex) {
222
+ const agents = {};
223
+ for (const [actionName, entry] of catalogIndex) {
224
+ agents[actionName] = buildCatalogAgent(actionName, entry);
49
225
  }
50
226
  return agents;
51
227
  }
@@ -118,11 +294,37 @@ export function buildConstraints() {
118
294
  };
119
295
  }
120
296
  /**
121
- * Generate the complete compact schema for workflow generation
297
+ * Generate the complete compact schema for workflow generation.
298
+ * API-first: fetches live action definitions when client is provided.
122
299
  */
123
- export function generateSchema() {
300
+ export async function generateSchema(client, cache) {
301
+ const invariants = cache?.getInvariants()?.length
302
+ ? cache.getInvariants()
303
+ : STRUCTURAL_INVARIANTS;
304
+ const { agents, source } = await buildCompactAgents(client);
305
+ return {
306
+ agents,
307
+ schema_source: source,
308
+ typeRules: buildTypeRules(),
309
+ inputRules: INPUT_SOURCE_RULES.map(rule => ({
310
+ action: rule.actionPattern,
311
+ recommended: rule.recommended,
312
+ avoid: rule.avoid,
313
+ severity: rule.severity,
314
+ })),
315
+ constraints: buildConstraints(),
316
+ structuralInvariants: invariants.map(({ id, rule, violation, fix, severity }) => ({ id, rule, violation, fix, severity })),
317
+ };
318
+ }
319
+ /** Synchronous version (catalog-only, no API). For callers that can't await. */
320
+ export function generateSchemaSync(cache) {
321
+ const invariants = cache?.getInvariants()?.length
322
+ ? cache.getInvariants()
323
+ : STRUCTURAL_INVARIANTS;
324
+ const { agents, source } = buildCompactAgentsSync();
124
325
  return {
125
- agents: buildCompactAgents(),
326
+ agents,
327
+ schema_source: source,
126
328
  typeRules: buildTypeRules(),
127
329
  inputRules: INPUT_SOURCE_RULES.map(rule => ({
128
330
  action: rule.actionPattern,
@@ -131,14 +333,15 @@ export function generateSchema() {
131
333
  severity: rule.severity,
132
334
  })),
133
335
  constraints: buildConstraints(),
336
+ structuralInvariants: invariants.map(({ id, rule, violation, fix, severity }) => ({ id, rule, violation, fix, severity })),
134
337
  };
135
338
  }
136
339
  /**
137
340
  * Generate a Markdown summary suitable for LLM prompts
138
341
  * This is ~5K tokens vs ~70K for full documentation
139
342
  */
140
- export function generateSchemaMarkdown() {
141
- const schema = generateSchema();
343
+ export function generateSchemaMarkdown(cache) {
344
+ const schema = generateSchemaSync(cache);
142
345
  let md = `# Workflow Generation Schema\n\n`;
143
346
  // Agent summary table with tier and description
144
347
  md += `## Available Agents (${Object.keys(schema.agents).length})\n\n`;
@@ -195,13 +398,23 @@ export function generateSchemaMarkdown() {
195
398
  for (const c of schema.constraints.inputFormats) {
196
399
  md += `- ${c}\n`;
197
400
  }
401
+ // Structural self-check — critical invariants the LLM must verify before outputting
402
+ const criticalInvariants = schema.structuralInvariants.filter(i => i.severity === "critical");
403
+ md += `\n## Self-Check (VERIFY BEFORE OUTPUTTING)\n\n`;
404
+ md += `Before finalizing your workflow, verify each rule:\n\n`;
405
+ md += `| # | Rule | Violation Example | Fix |\n`;
406
+ md += `|---|------|-------------------|-----|\n`;
407
+ for (let i = 0; i < criticalInvariants.length; i++) {
408
+ const inv = criticalInvariants[i];
409
+ md += `| ${i + 1} | ${inv.rule} | ${inv.violation} | ${inv.fix} |\n`;
410
+ }
198
411
  return md;
199
412
  }
200
413
  /**
201
414
  * Get compact agent info for a specific action
202
415
  */
203
416
  export function getAgentSchema(actionName) {
204
- const schema = generateSchema();
417
+ const schema = generateSchemaSync();
205
418
  return schema.agents[actionName];
206
419
  }
207
420
  /**
@@ -215,7 +428,7 @@ export function isTypeCompatible(sourceType, targetType) {
215
428
  if (sourceType === targetType)
216
429
  return true;
217
430
  // Check explicit rules
218
- const schema = generateSchema();
431
+ const schema = generateSchemaSync();
219
432
  const rule = schema.typeRules.find(r => r.sourceType === sourceType);
220
433
  return rule?.targetTypes.includes(targetType) ?? false;
221
434
  }