@dypai-ai/mcp 1.2.3 → 1.3.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.
@@ -105,6 +105,339 @@ Declarative snapshot of your DYPAI project's backend.
105
105
 
106
106
  Paths inside YAML (e.g. \`query_file: sql/create_invoice.sql\`) are always relative
107
107
  to this folder's root, regardless of where the YAML lives.
108
+
109
+ ## Reference examples
110
+
111
+ When the project has no endpoints yet, \`dypai_pull\` writes three reference
112
+ files (all with the \`.disabled\` suffix so \`dypai_push\` ignores them):
113
+
114
+ - \`endpoints/_example.yaml.disabled\` full workflow tour
115
+ - \`endpoints/_example-tool.yaml.disabled\` endpoint marked \`tool: true\`
116
+ - \`endpoints/_example-agent.yaml.disabled\` endpoint with an agent node using the tool
117
+
118
+ Read them to learn the canonical YAML format (placeholders, branching, error
119
+ handling, agent tools). Copy + adapt:
120
+
121
+ cp endpoints/_example.yaml.disabled endpoints/my-feature.yaml
122
+ cp endpoints/_example-tool.yaml.disabled endpoints/search-products.yaml
123
+ cp endpoints/_example-agent.yaml.disabled endpoints/chat.yaml
124
+ `
125
+
126
+ // Reference endpoint that ships with empty projects so the agent can learn
127
+ // the YAML format by reading a real, complete example. Covers: trigger, input
128
+ // schema, output schema, multi-node workflow, ${input.x} / ${nodes.x.y} /
129
+ // ${current_user_id} placeholders, expressions, branching with `handle`,
130
+ // per-node error handling, multiple return paths, *_file references.
131
+ const EXAMPLE_ENDPOINT_YAML = `# ============================================================================
132
+ # DYPAI endpoint reference — complete tour of the YAML format.
133
+ # This file is IGNORED by dypai_push, dypai_validate, dypai_diff and
134
+ # dypai_test_endpoint (the .disabled extension keeps it out of every flow).
135
+ #
136
+ # Copy + adapt when authoring a new endpoint:
137
+ # cp endpoints/_example.yaml.disabled endpoints/my-feature.yaml
138
+ # Then edit and remove the .disabled suffix.
139
+ # ============================================================================
140
+
141
+ name: example-process-order
142
+ description: |
143
+ Reference endpoint demonstrating the canonical YAML format:
144
+ trigger, input/output schemas, multi-node workflow, placeholders,
145
+ branching, per-node error handling, and multiple return paths.
146
+ method: POST
147
+
148
+ # ─── Trigger ────────────────────────────────────────────────────────────────
149
+ # Pick exactly one. Options:
150
+ # trigger: { http_api: { auth_mode: jwt | api_key | public } }
151
+ # trigger: { webhook: {} } # path is auto-generated
152
+ # trigger: { schedule: { cron: "0 9 * * 1-5", timezone: Europe/Madrid } }
153
+ # trigger: { telegram: {} } # incoming Telegram messages
154
+ #
155
+ # auth_mode notes:
156
+ # jwt — user-authenticated. \${current_user_id} / \${current_user_role} available.
157
+ # api_key — server-to-server. No user context.
158
+ # public — anonymous. Use only for read-only, non-sensitive data.
159
+ trigger:
160
+ http_api:
161
+ auth_mode: jwt
162
+
163
+ # ─── Input schema (JSON Schema) ─────────────────────────────────────────────
164
+ # Validates the request body. Fields here are referenced via \${input.<field>}.
165
+ # The validator (dypai_validate) catches typos in placeholders against this.
166
+ input:
167
+ type: object
168
+ required: [product_id, quantity]
169
+ properties:
170
+ product_id:
171
+ type: string
172
+ format: uuid
173
+ quantity:
174
+ type: integer
175
+ minimum: 1
176
+ note:
177
+ type: string
178
+
179
+ # ─── Output schema (optional but recommended) ───────────────────────────────
180
+ # Describes the response shape. Helps the validator + frontend type-checkers.
181
+ output:
182
+ type: object
183
+ properties:
184
+ order_id: { type: string }
185
+ total: { type: number }
186
+ status: { type: string }
187
+
188
+ # ─── Workflow ───────────────────────────────────────────────────────────────
189
+ # Nodes are the steps. Placeholders wire data flow between them:
190
+ #
191
+ # \${input.<field>} — the request body / query params
192
+ # \${nodes.<id>.<field>} — output of a previous node
193
+ # \${current_user_id} — UUID of the JWT-authenticated user
194
+ # \${current_user_role} — role name from the JWT
195
+ #
196
+ # Expressions inside placeholders work: arithmetic, comparisons, JS-ish.
197
+ # \${input.qty * 2}, \${nodes.x.stock >= input.qty}
198
+ #
199
+ # In SQL contexts (custom_query, dypai_database queries) the engine
200
+ # automatically casts placeholders by value shape:
201
+ # UUID-shaped string → binds as ::uuid
202
+ # plain object/array → binds as ::jsonb
203
+ # Date instance → binds as ::timestamptz
204
+ # text / int / bool → binds without an explicit cast (Postgres infers)
205
+ # So you write SQL naturally — DON'T add '\${current_user_id}'::uuid manually.
206
+ # Just write: WHERE user_id = \${current_user_id}
207
+ workflow:
208
+ nodes:
209
+
210
+ # 1. Read the product. \`operation: query\` for ANY read — pure SQL.
211
+ - id: get_product
212
+ type: dypai_database
213
+ operation: query
214
+ query: SELECT * FROM products WHERE id = \${input.product_id} LIMIT 1
215
+ # Output is accessible as \${nodes.get_product.<column>} (first row's columns)
216
+
217
+ # 2. Branch on stock availability.
218
+ # \`logic\` nodes with \`operation: if\` send flow down two edges
219
+ # differentiated by \`handle: "true"\` and \`handle: "false"\`.
220
+ - id: check_stock
221
+ type: logic
222
+ operation: if
223
+ condition: \${nodes.get_product.stock >= input.quantity}
224
+
225
+ # 3. Insert the order. \`operation: mutation\` for trivial single-table writes
226
+ # — declarative, no SQL needed. The shape (insert/update/delete) decides.
227
+ - id: insert_order
228
+ type: dypai_database
229
+ operation: mutation
230
+ table: orders
231
+ insert:
232
+ user_id: \${current_user_id} # JWT identity
233
+ product_id: \${input.product_id} # request input
234
+ quantity: \${input.quantity}
235
+ unit_price: \${nodes.get_product.price} # cross-node ref
236
+ total: \${nodes.get_product.price * input.quantity} # expression
237
+ note: \${input.note}
238
+ returning: "*"
239
+ # For UPSERT: add on_conflict: { columns: [id], action: update }
240
+ # For UPDATE: replace \`insert:\` with \`update: {...}\` + \`where: {...}\`
241
+ # For DELETE: replace \`insert:\` with \`delete: true\` + \`where: {...}\`
242
+ #
243
+ # Long SQL (>500 chars) inside \`operation: query\` is auto-extracted on
244
+ # \`dypai_pull\` to sql/<endpoint>.sql and referenced like:
245
+ # query_file: sql/insert_order.sql
246
+ # Same for prompts (system_prompt_file) and JS/Python code (code_file).
247
+
248
+ # 4. Notify a downstream system. \`on_error_step_id\` routes failures
249
+ # to \`build_response\` so a notification outage doesn't 500 the order.
250
+ - id: notify
251
+ type: http_request
252
+ method: POST
253
+ url: https://hooks.example.com/orders
254
+ body:
255
+ order_id: \${nodes.insert_order.id}
256
+ total: \${nodes.insert_order.total}
257
+ on_error_step_id: build_response
258
+
259
+ # 5. Build the success response. Exactly one node per code path needs
260
+ # \`return: true\` — it marks that node's output as the HTTP body.
261
+ - id: build_response
262
+ type: set_fields
263
+ return: true
264
+ fields:
265
+ order_id: \${nodes.insert_order.id}
266
+ total: \${nodes.insert_order.total}
267
+ status: created
268
+
269
+ # 6. Error path: out of stock. Different shape, also \`return: true\`.
270
+ # Multiple return nodes are fine as long as only one runs per execution.
271
+ - id: out_of_stock
272
+ type: set_fields
273
+ return: true
274
+ fields:
275
+ error: out_of_stock
276
+ message: Not enough stock for the requested quantity
277
+ available: \${nodes.get_product.stock}
278
+ requested: \${input.quantity}
279
+
280
+ # ─── Edges ────────────────────────────────────────────────────────────────
281
+ # Connect nodes. Form: { from: <id>, to: <id> }.
282
+ # For \`logic\` if-nodes use \`handle: "true"\` / \`handle: "false"\` to pick the branch.
283
+ edges:
284
+ - { from: get_product, to: check_stock }
285
+ - { from: check_stock, to: insert_order, handle: "true" }
286
+ - { from: check_stock, to: out_of_stock, handle: "false" }
287
+ - { from: insert_order, to: notify }
288
+ - { from: notify, to: build_response }
289
+
290
+ # \`on_error\` at the workflow level: stop (default) | continue
291
+ # on_error: stop
292
+ `
293
+
294
+ // Reference TOOL endpoint — an endpoint marked \`tool: true\` so an \`agent\` node
295
+ // elsewhere can invoke it. Pair with EXAMPLE_AGENT_ENDPOINT_YAML below.
296
+ const EXAMPLE_TOOL_ENDPOINT_YAML = `# ============================================================================
297
+ # DYPAI reference — endpoint exposed as a TOOL for \`agent\` nodes.
298
+ # Ignored by dypai_push/validate/diff/test_endpoint (.disabled suffix).
299
+ #
300
+ # Pair with _example-agent.yaml.disabled — that one has an agent node that
301
+ # references THIS endpoint by name.
302
+ #
303
+ # Copy + adapt:
304
+ # cp endpoints/_example-tool.yaml.disabled endpoints/search-products.yaml
305
+ # ============================================================================
306
+
307
+ name: example-search-products
308
+ description: Search the product catalog. Exposed as a tool for agent nodes.
309
+ method: GET
310
+
311
+ # ─── Tool flag + description ────────────────────────────────────────────────
312
+ # \`tool: true\` exposes this endpoint to agent nodes (writes is_tool=true in DB).
313
+ # \`tool_description\` is the prompt the LLM reads when deciding to call it.
314
+ # Be SPECIFIC about when to use it and what it returns — vague descriptions
315
+ # cause the LLM to skip useful tools or misuse them.
316
+ tool: true
317
+ tool_description: |
318
+ Search the product catalog by free-text query.
319
+ Use when the user asks about availability, prices, or categories.
320
+ Returns up to \`limit\` products with id, name, price, stock.
321
+
322
+ # ─── Input schema (tight JSON Schema with descriptions) ─────────────────────
323
+ # The LLM uses THIS schema to fabricate tool arguments. Every field benefits
324
+ # from a \`description\`; typed and required fields keep hallucinated args out.
325
+ input:
326
+ type: object
327
+ required: [query]
328
+ properties:
329
+ query:
330
+ type: string
331
+ description: Free-text search term (matches product name, case-insensitive).
332
+ limit:
333
+ type: integer
334
+ default: 10
335
+ minimum: 1
336
+ maximum: 50
337
+ description: Max results to return.
338
+
339
+ # ─── Output schema (optional but recommended for tools) ─────────────────────
340
+ output:
341
+ type: object
342
+ properties:
343
+ results:
344
+ type: array
345
+ items:
346
+ type: object
347
+ properties:
348
+ id: { type: string }
349
+ name: { type: string }
350
+ price: { type: number }
351
+ stock: { type: integer }
352
+
353
+ # ─── Trigger ────────────────────────────────────────────────────────────────
354
+ # Agent calls are server-to-server → \`api_key\`. Use \`jwt\` only if the tool
355
+ # needs \${current_user_id}. NEVER \`public\` for a tool that writes data.
356
+ trigger:
357
+ http_api:
358
+ auth_mode: api_key
359
+
360
+ workflow:
361
+ nodes:
362
+ - id: run
363
+ type: dypai_database
364
+ operation: query
365
+ return: true
366
+ query: |
367
+ SELECT id, name, price, stock
368
+ FROM products
369
+ WHERE name ILIKE '%' || \${input.query} || '%'
370
+ ORDER BY name
371
+ LIMIT \${input.limit}
372
+ `
373
+
374
+ // Reference endpoint with an \`agent\` node that USES a tool endpoint by name.
375
+ // Pair with EXAMPLE_TOOL_ENDPOINT_YAML above.
376
+ const EXAMPLE_AGENT_ENDPOINT_YAML = `# ============================================================================
377
+ # DYPAI reference — endpoint with an \`agent\` node that invokes tool endpoints.
378
+ # Ignored by dypai_push/validate/diff/test_endpoint (.disabled suffix).
379
+ #
380
+ # Pair with _example-tool.yaml.disabled — this file references that one by name.
381
+ #
382
+ # Copy + adapt:
383
+ # cp endpoints/_example-agent.yaml.disabled endpoints/chat.yaml
384
+ # ============================================================================
385
+
386
+ name: example-chat-assistant
387
+ description: Chat assistant that uses tool endpoints to answer questions about products.
388
+ method: POST
389
+
390
+ trigger:
391
+ http_api:
392
+ auth_mode: jwt # user-authenticated chat
393
+
394
+ input:
395
+ type: object
396
+ required: [message]
397
+ properties:
398
+ message:
399
+ type: string
400
+ description: The user's message to the assistant.
401
+
402
+ output:
403
+ type: object
404
+ properties:
405
+ reply: { type: string }
406
+ tool_calls: { type: array }
407
+
408
+ workflow:
409
+ nodes:
410
+ - id: chat
411
+ type: agent
412
+ provider: openai # openai | anthropic | google
413
+ model: gpt-4o # model id — see list via UI or search_docs
414
+ credential: openai-prod # credential NAME — must exist (get_app_credentials)
415
+ return: true
416
+
417
+ system_prompt: |
418
+ You are a helpful shop assistant. You have access to tools for searching
419
+ the product catalog and sending emails. When the user asks about
420
+ availability, prices, or categories, call example-search-products.
421
+ Always be concise and friendly.
422
+
423
+ input: \${input.message} # what the user said → the agent's turn
424
+
425
+ # ─── Tools (IMPORTANT — BY NAME, not UUID) ─────────────────────────────
426
+ # In YAML the parameter is \`tools: [<endpoint_name>, ...]\`. The codec
427
+ # resolves names ↔ UUIDs automatically on push/pull. Writing
428
+ # \`tool_ids: [...]\` in YAML BYPASSES the codec and fails in production.
429
+ #
430
+ # Every name here MUST reference an endpoint with \`tool: true\`.
431
+ # dypai_validate catches typos (rule: agent_tool_not_found) and lists
432
+ # the available tool endpoints in its fix_hint.
433
+ tools:
434
+ - example-search-products # ← references the other example file
435
+ # - example-send-email # add more tools as you define them
436
+
437
+ # ─── Agent behavior tuning ─────────────────────────────────────────────
438
+ max_iterations: 5 # max rounds of tool-calling (anti-runaway)
439
+ temperature: 0.7
440
+ tool_timeout: 30 # seconds per tool call
108
441
  `
109
442
 
110
443
  async function execSql(projectId, sql) {
@@ -150,10 +483,13 @@ function renderYaml(doc) {
150
483
  export const dypaiPullTool = {
151
484
  name: "dypai_pull",
152
485
  description:
153
- "Serializes the remote project state to local YAML files under ./dypai/. " +
154
- "Writes endpoints/<name>.yaml + sql/ and prompts/ for extracted content. " +
486
+ "Downloads BACKEND state (endpoints, SQL, prompts, node catalog, realtime policies, schema) to local YAML files under ./dypai/. " +
487
+ "Writes endpoints/<name>.yaml + sql/ + prompts/ + code/ for extracted content. " +
155
488
  "Canvas positions are stripped (regenerated by visual editor). Safe to run repeatedly. " +
156
- "Use this to start editing a project locally with your editor + AI agent. " +
489
+ "Use this to start editing a project locally with your editor + AI agent.\n\n" +
490
+ "SCOPE: backend only. This does NOT download frontend React/Vite source code — for that call " +
491
+ "`manage_frontend(operation: \"sync\", targetDirectory: <abs>)`. The two are independent; run both when " +
492
+ "starting fresh on a full-stack project.\n\n" +
157
493
  "IMPORTANT: when called by an IDE-hosted MCP, the process cwd is often the user's home dir — " +
158
494
  "pass an ABSOLUTE path in out_dir (e.g. /Users/me/projects/my-app/dypai) to avoid writing to the wrong place. " +
159
495
  "If out_dir is relative, the tool tries to auto-detect the workspace via env vars and git markers, " +
@@ -321,6 +657,30 @@ export const dypaiPullTool = {
321
657
  }
322
658
  }
323
659
 
660
+ // Reference examples: only ship them on empty projects so the agent has
661
+ // canonical YAML tours to learn from. Once the project has real endpoints,
662
+ // those serve as the live examples — no need for the disabled stubs.
663
+ // .disabled suffix keeps them out of validate / diff / push / test_endpoint.
664
+ //
665
+ // Three separate files, each copy-ready for a different pattern:
666
+ // _example.yaml.disabled — full workflow tour (trigger, nodes, edges, branching)
667
+ // _example-tool.yaml.disabled — endpoint exposed as a tool for agent nodes
668
+ // _example-agent.yaml.disabled — endpoint with an agent node that uses the tool above
669
+ if (endpoints.length === 0) {
670
+ const refs = [
671
+ ["endpoints/_example.yaml.disabled", EXAMPLE_ENDPOINT_YAML],
672
+ ["endpoints/_example-tool.yaml.disabled", EXAMPLE_TOOL_ENDPOINT_YAML],
673
+ ["endpoints/_example-agent.yaml.disabled", EXAMPLE_AGENT_ENDPOINT_YAML],
674
+ ]
675
+ for (const [relPath, content] of refs) {
676
+ const fullPath = join(outDir, relPath)
677
+ try { await access(fullPath) } catch {
678
+ await writeFileEnsured(fullPath, content)
679
+ filesWritten.push(relPath)
680
+ }
681
+ }
682
+ }
683
+
324
684
  // Fetch project metadata to persist identity in the committed config
325
685
  let projectInfo = null
326
686
  if (project_id) {
@@ -308,6 +308,14 @@ export const dypaiPushTool = {
308
308
  }
309
309
  }
310
310
 
311
+ if (!remote || !remote.mapsCtx) {
312
+ return {
313
+ success: false,
314
+ phase: "fetch_remote",
315
+ error: "Remote state could not be fetched (mapsCtx missing). Check your DYPAI_TOKEN and project_id.",
316
+ }
317
+ }
318
+
311
319
  const plan = computePlan(local, remote, remote.mapsCtx, {
312
320
  deleteOrphans: delete_orphans,
313
321
  stateSnapshot,
@@ -15,6 +15,7 @@ import YAML from "yaml"
15
15
  import { proxyToolCall } from "../proxy.js"
16
16
  import { deserializeEndpoint } from "./codec.js"
17
17
  import { readLocalConfig, fetchRemoteState } from "./planner.js"
18
+ import { runValidation } from "./validate.js"
18
19
  import { summarizeTestWorkflowResponse } from "../trace-summarize.js"
19
20
 
20
21
  // ─── Local endpoint file discovery ──────────────────────────────────────────
@@ -43,6 +44,9 @@ async function findEndpointByName(rootDir, name) {
43
44
  const full = join(dir, e.name)
44
45
  if (e.isDirectory()) await walk(full)
45
46
  else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
47
+ // Skip disabled reference files (e.g. _example.yaml.disabled — already
48
+ // filtered by extension above, but also skip *.disabled.yaml form).
49
+ if (e.name.endsWith(".disabled.yaml") || e.name.endsWith(".disabled.yml")) continue
46
50
  candidates.push(full)
47
51
  }
48
52
  }
@@ -109,11 +113,16 @@ export const dypaiTestEndpointTool = {
109
113
  type: "string",
110
114
  description: "Project UUID. Auto-resolved from dypai.config.yaml.",
111
115
  },
116
+ skip_validation: {
117
+ type: "boolean",
118
+ description: "Skip the pre-flight validate pass. Default false — useful only when you intentionally want to send a malformed YAML to the engine to inspect its raw error.",
119
+ default: false,
120
+ },
112
121
  },
113
122
  required: ["endpoint"],
114
123
  },
115
124
 
116
- async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id } = {}) {
125
+ async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id, skip_validation = false } = {}) {
117
126
  const rootDir = resolvePath(process.cwd(), root_dir)
118
127
 
119
128
  if (!endpoint) {
@@ -157,13 +166,49 @@ export const dypaiTestEndpointTool = {
157
166
  let mapsCtx
158
167
  try {
159
168
  const remote = await fetchRemoteState(targetProjectId)
160
- mapsCtx = remote.mapsCtx
169
+ mapsCtx = remote?.mapsCtx
161
170
  } catch (e) {
162
171
  return {
163
172
  success: false,
164
173
  error: `Could not fetch remote state to resolve credential/tool references: ${e.message}`,
165
174
  }
166
175
  }
176
+ if (!mapsCtx) {
177
+ return {
178
+ success: false,
179
+ error: "Remote state returned without mapsCtx — cannot resolve credential/tool references.",
180
+ hint: "Check your DYPAI_TOKEN and that the project_id is correct.",
181
+ }
182
+ }
183
+
184
+ // ── Pre-flight validation (skippable) ────────────────────────────────────
185
+ // Run the same linter dypai_push uses, but filter to errors that affect THIS
186
+ // endpoint only — warnings and other-endpoint diagnostics shouldn't block
187
+ // a focused test. Catches things like mutation+query_file, missing where,
188
+ // unknown node types, placeholder typos, etc. before the engine sees them.
189
+ if (!skip_validation) {
190
+ try {
191
+ const valResult = await runValidation(rootDir, targetProjectId)
192
+ const relevantErrors = (valResult.diagnostics || []).filter(d =>
193
+ d.severity === "error" && d.endpoint === found.doc.name
194
+ )
195
+ if (relevantErrors.length > 0) {
196
+ return {
197
+ success: false,
198
+ phase: "pre_flight_validation",
199
+ endpoint,
200
+ file: found.full.replace(rootDir + "/", ""),
201
+ error: `${relevantErrors.length} validation error(s) in this endpoint — fix them before testing.`,
202
+ errors: relevantErrors,
203
+ hint: "Each error includes a fix_hint. Address them and re-run dypai_test_endpoint. Pass skip_validation: true if you want to bypass this and see the raw engine response.",
204
+ }
205
+ }
206
+ } catch (e) {
207
+ // Non-fatal: if validation itself crashes, continue to the test so
208
+ // the agent at least gets the engine's response. Logged as a warning.
209
+ process.stderr.write(`[dypai_test_endpoint] pre-flight validation failed: ${e.message}\n`)
210
+ }
211
+ }
167
212
 
168
213
  // Deserialize the local YAML to the engine-shaped workflow_code
169
214
  const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
@@ -190,13 +235,41 @@ export const dypaiTestEndpointTool = {
190
235
 
191
236
  try {
192
237
  const result = await proxyToolCall("test_workflow", execArgs)
238
+
239
+ // Defensive: the remote sometimes returns a plain error string (not a
240
+ // structured object) when something fails outside the workflow trace —
241
+ // e.g. SQL operator errors before the trace is built. Spreading a string
242
+ // with `...` would explode it character-by-character into the response,
243
+ // which is what produced the {"0":"o","1":"p",...} bug. Detect and wrap.
244
+ if (typeof result === "string") {
245
+ return {
246
+ success: false,
247
+ endpoint,
248
+ file: found.full.replace(rootDir + "/", ""),
249
+ as_user: as_user || null,
250
+ error: result.length > 2000 ? result.slice(0, 2000) + "...[truncated]" : result,
251
+ hint: "The remote returned a raw error string (no per-node trace available). Read the error above for the root cause.",
252
+ }
253
+ }
254
+ if (!result || typeof result !== "object") {
255
+ return {
256
+ success: false,
257
+ endpoint,
258
+ file: found.full.replace(rootDir + "/", ""),
259
+ as_user: as_user || null,
260
+ error: `Unexpected response type from remote test_workflow: ${typeof result}`,
261
+ raw_response: result,
262
+ }
263
+ }
264
+
193
265
  // Summarize the trace just like the direct test_workflow path.
194
266
  const summarized = summarizeTestWorkflowResponse(result, trace_mode)
267
+ const safeSummary = (summarized && typeof summarized === "object" && !Array.isArray(summarized)) ? summarized : { result: summarized }
195
268
  return {
196
269
  endpoint,
197
270
  file: found.full.replace(rootDir + "/", ""),
198
271
  as_user: as_user || null,
199
- ...summarized,
272
+ ...safeSummary,
200
273
  }
201
274
  } catch (e) {
202
275
  return {
@@ -142,6 +142,11 @@ export const NODE_FIELD_TRANSFORMS = [
142
142
  // ─── Engine ────────────────────────────────────────────────────────────────
143
143
 
144
144
  function applyTransforms(input, direction, ctx, nodeType) {
145
+ // Defensive: an agent passing null parameters or non-objects shouldn't crash
146
+ // 6 functions deep. Treat as empty params instead.
147
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
148
+ return {}
149
+ }
145
150
  let result = { ...input }
146
151
  for (const t of NODE_FIELD_TRANSFORMS) {
147
152
  if (t.appliesWhen && !t.appliesWhen(nodeType)) continue