@saltcorn/copilot 0.8.1 → 0.8.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.
package/builder-gen.js CHANGED
@@ -79,7 +79,9 @@ only use "show_with_html" when the task explicitly requires rendering HTML conte
79
79
 
80
80
  const LISTCOLUMNS_VIEW_LINK = `\
81
81
  A view_link that opens the detail of a row MUST point to a Show-type view (viewtemplate "Show"). \
82
- Only include such a view_link if a Show view for this table is listed in the available views.`;
82
+ Only include such a view_link if a Show view for this table is listed in the available views. \
83
+ Every view_link requires a "relation" string — you MUST call get_relation_paths before generating the layout \
84
+ whenever the layout will contain any view_link or embedded view segment.`;
83
85
 
84
86
  // ── Composed MODE_GUIDANCE ────────────────────────────────────────────────────
85
87
 
@@ -805,7 +807,8 @@ const normalizeSegment = (segment, ctx) => {
805
807
  let viewLabel = clone.view_label || clone.view;
806
808
  let isFormula = clone.isFormula || {};
807
809
  const tmplMatch =
808
- typeof viewLabel === "string" && viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
810
+ typeof viewLabel === "string" &&
811
+ viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
809
812
  if (tmplMatch) {
810
813
  viewLabel = tmplMatch[1];
811
814
  isFormula = { ...isFormula, label: true };
@@ -843,7 +846,9 @@ const normalizeSegment = (segment, ctx) => {
843
846
  child == null ? null : normalizeSegment(child, ctx)
844
847
  );
845
848
  if (!besides.some(Boolean)) return null;
846
- return { ...clone, besides, list_columns: true };
849
+ // Drop 'type' — List viewtemplate expects { besides, list_columns: true } with no type field
850
+ const { type: _t, ...rest } = clone;
851
+ return { ...rest, besides, list_columns: true };
847
852
  }
848
853
  case "list_column": {
849
854
  let raw = clone.contents;
@@ -961,7 +966,21 @@ const normalizeLayoutCandidate = (candidate, ctx) => {
961
966
  normalized = convertForeignLayout(candidate, ctx);
962
967
  }
963
968
  if (!normalized) return null;
964
- const layout = Array.isArray(normalized) ? { above: normalized } : normalized;
969
+ let layout = Array.isArray(normalized) ? { above: normalized } : normalized;
970
+
971
+ // For listcolumns mode: if the LLM wrapped the list in a stack, unwrap it
972
+ if (
973
+ ctx.mode === "listcolumns" &&
974
+ layout.type === "stack" &&
975
+ Array.isArray(layout.above)
976
+ ) {
977
+ const listChild = layout.above.find((c) => c?.list_columns);
978
+ if (listChild) {
979
+ const { type: _t, ...rest } = listChild;
980
+ layout = rest;
981
+ }
982
+ }
983
+
965
984
  return sanitizeNoHtmlSegments(layout);
966
985
  };
967
986
 
@@ -1000,6 +1019,11 @@ const buildPromptText = (userPrompt, ctx, schema) => {
1000
1019
  schema
1001
1020
  )}`
1002
1021
  );
1022
+ parts.push(
1023
+ `TOOL CALL REQUIRED: If the layout you are about to generate contains any view_link or embedded view (type "view") segment, ` +
1024
+ `you MUST call get_relation_paths with all required source_table/target_view pairs BEFORE returning JSON. ` +
1025
+ `Do NOT guess or omit the "relation" field — return the tool call first and use the result in the layout.`
1026
+ );
1003
1027
  parts.push(
1004
1028
  `Based on the schema above, process the following user request and generate the layout JSON. Reminder: ONLY output valid JSON starting with { and ending with }, no markdown fences.\nUser request:\n"${userPrompt}"`
1005
1029
  );
@@ -1220,7 +1244,6 @@ const buildBracketObject = (node) => {
1220
1244
  return obj;
1221
1245
  };
1222
1246
 
1223
-
1224
1247
  const buildContext = async (mode, tableName) => {
1225
1248
  const normalizedMode = (mode || "show").toLowerCase();
1226
1249
  const ctx = {
@@ -1400,16 +1423,19 @@ const buildErrorLayout = ({ message, mode, table }) => {
1400
1423
  };
1401
1424
  };
1402
1425
 
1403
- const GET_RELATION_PATHS_TOOL = { type: "function", function: GET_RELATION_PATHS_FUNCTION };
1426
+ const GET_RELATION_PATHS_TOOL = {
1427
+ type: "function",
1428
+ function: GET_RELATION_PATHS_FUNCTION,
1429
+ };
1404
1430
 
1405
1431
  /**
1406
- * Run the LLM giving it one opportunity to call get_relation_paths before
1407
- * producing the final layout JSON. Returns the final text response.
1432
+ * Run the LLM allowing up to MAX_TOOL_ROUNDS get_relation_paths calls (depth
1433
+ * escalation: 2 4 6) before producing the final layout JSON.
1408
1434
  */
1435
+ const MAX_TOOL_ROUNDS = 4;
1409
1436
  const runWithRelationTools = async (llm, mainPrompt, opts) => {
1410
1437
  const llm_add_message = getState().functions.llm_add_message;
1411
1438
 
1412
- // Local chat copy with the main prompt pre-loaded so all iterations see it.
1413
1439
  const runChat = Array.isArray(opts.chat) ? [...opts.chat] : [];
1414
1440
  runChat.push({ role: "user", content: mainPrompt });
1415
1441
 
@@ -1418,35 +1444,54 @@ const runWithRelationTools = async (llm, mainPrompt, opts) => {
1418
1444
  delete toolOpts.response_format;
1419
1445
  delete toolOpts.chat;
1420
1446
 
1421
- // Call 1: model either returns JSON directly or calls get_relation_paths.
1422
- const raw = await llm.run(null, { ...toolOpts, chat: runChat });
1423
- if (!raw?.hasToolCalls) {
1424
- return typeof raw === "string" ? raw : raw?.content ?? "";
1425
- }
1447
+ const schemaData = await build_schema_data();
1426
1448
 
1427
- // Append the assistant message so call 2 has full context.
1428
- if (raw.ai_sdk && Array.isArray(raw.messages)) {
1429
- raw.messages.filter((m) => m.role === "assistant").forEach((m) => runChat.push(m));
1430
- } else if (raw.tool_calls) {
1431
- runChat.push({ role: "assistant", content: raw.content || null, tool_calls: raw.tool_calls });
1432
- }
1449
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
1450
+ const raw = await llm.run(null, { ...toolOpts, chat: runChat });
1433
1451
 
1434
- // Resolve the tool call(s) and push results.
1435
- const schemaData = await build_schema_data();
1436
- for (const tc of raw.getToolCalls()) {
1437
- let resultText;
1438
- if (tc.tool_name === "get_relation_paths") {
1439
- const sections = getRelationPathsForPairs(tc.input.pairs || [], schemaData);
1440
- resultText =
1441
- sections.join("\n\n") +
1442
- `\n\nSet the "relation" property to one of the strings listed above for each view_link.`;
1443
- } else {
1444
- resultText = `Unknown tool: ${tc.tool_name}`;
1452
+ if (!raw?.hasToolCalls) {
1453
+ return typeof raw === "string" ? raw : raw?.content ?? "";
1454
+ }
1455
+
1456
+ // Append the assistant message so the next round has full context.
1457
+ if (raw.ai_sdk && Array.isArray(raw.messages)) {
1458
+ raw.messages
1459
+ .filter((m) => m.role === "assistant")
1460
+ .forEach((m) => runChat.push(m));
1461
+ } else if (raw.tool_calls) {
1462
+ runChat.push({
1463
+ role: "assistant",
1464
+ content: raw.content || null,
1465
+ tool_calls: raw.tool_calls,
1466
+ });
1467
+ }
1468
+
1469
+ for (const tc of raw.getToolCalls()) {
1470
+ let resultText;
1471
+ if (tc.tool_name === "get_relation_paths") {
1472
+ const maxDepth = tc.input.max_depth ?? 2;
1473
+ const sections = getRelationPathsForPairs(
1474
+ tc.input.pairs || [],
1475
+ schemaData,
1476
+ maxDepth
1477
+ );
1478
+ resultText =
1479
+ sections.join("\n\n") +
1480
+ `\n\nFor each pair above: analyse whether the listed paths include a suitable one for the intended relation type (ChildList, ParentShow, Own, etc.). ` +
1481
+ `If a suitable path exists, set the "relation" property to the chosen path string for all types including Own. ` +
1482
+ `If no suitable path exists for a pair (no paths listed, or none match the intended type), call get_relation_paths again with a higher max_depth (4, then 6). ` +
1483
+ `Do not escalate just because multiple paths are listed — only escalate when none is appropriate.`;
1484
+ } else {
1485
+ resultText = `Unknown tool: ${tc.tool_name}`;
1486
+ }
1487
+ await llm_add_message.run("tool_response", resultText, {
1488
+ chat: runChat,
1489
+ tool_call: tc,
1490
+ });
1445
1491
  }
1446
- await llm_add_message.run("tool_response", resultText, { chat: runChat, tool_call: tc });
1447
1492
  }
1448
1493
 
1449
- // Call 2: model has relation paths, now generates the layout JSON.
1494
+ // Exhausted tool rounds force final JSON answer.
1450
1495
  const final = await llm.run(null, { ...opts, chat: runChat });
1451
1496
  return typeof final === "string" ? final : final?.content ?? "";
1452
1497
  };
@@ -1564,6 +1609,7 @@ module.exports = {
1564
1609
  },
1565
1610
  isAsync: true,
1566
1611
  description: "Generate a builder layout",
1612
+ hidden: true,
1567
1613
  arguments: [
1568
1614
  { name: "prompt", type: "String" },
1569
1615
  { name: "mode", type: "String" },
@@ -62,6 +62,7 @@ const get_agent_view = () => {
62
62
  min_role: 1,
63
63
  configuration: {
64
64
  agent_action,
65
+ show_prev_runs: true,
65
66
  viewname,
66
67
  },
67
68
  });
package/index.js CHANGED
@@ -18,7 +18,6 @@ module.exports = {
18
18
  dependencies: ["@saltcorn/large-language-model", "@saltcorn/agents"],
19
19
  viewtemplates: features.workflows
20
20
  ? [
21
- require("./chat-copilot"),
22
21
  require("./user-copilot"),
23
22
  require("./copilot-as-agent"),
24
23
  require("./app-constructor/view.js"),
package/js-code-gen.js CHANGED
@@ -57,6 +57,7 @@ module.exports = {
57
57
  },
58
58
  isAsync: true,
59
59
  description: "Generate JavaScript code for an action",
60
+ hidden: true,
60
61
  arguments: [
61
62
  { name: "description", type: "String" },
62
63
  { name: "existing_code", type: "String" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {
package/relation-paths.js CHANGED
@@ -36,66 +36,72 @@ const { RelationsFinder, RelationType } = require("@saltcorn/common-code");
36
36
  const RELATION_PATH_DOC = `
37
37
  ## Relation paths
38
38
 
39
- Relation paths connect a view_link column or embedded view (type "view") segment to its
40
- target view. There are two formats — **new** (preferred) and **legacy** (read-only).
39
+ Every view_link and embedded view segment requires two fields:
40
+ - \`view\`: the view name (plain string, e.g. \`"packing_items_list"\`)
41
+ - \`relation\`: a dot-separated path string (e.g. \`".trips.packing_items$trip_id"\`)
41
42
 
42
- ---
43
+ **Always use this format. Never generate anything else.**
43
44
 
44
- ### New format (always use this when generating or updating)
45
+ ---
45
46
 
46
- Two separate fields:
47
- - \`view\`: just the view name, e.g. \`"packing_items_list"\`
48
- - \`relation\`: a dot-separated path string, e.g. \`".trips.packing_items$trip_id"\`
47
+ ### Relation path format
49
48
 
50
- **Path string format:** \`.sourcetable.segment1.segment2...\`
49
+ \`.sourcetable.segment1.segment2...\`
51
50
 
52
51
  Segment types:
53
- - **Outbound FK** (navigate to a parent): FK field name alone — e.g. \`trip_id\`
54
- - **Inbound FK** (collect child rows): \`childtable$fkfield\` — e.g. \`packing_items$trip_id\`
52
+ - **Outbound FK** (to a parent): FK field name alone — e.g. \`trip_id\`
53
+ - **Inbound FK** (child rows): \`childtable$fkfield\` — e.g. \`packing_items$trip_id\`
54
+ - **Same table** (no FK traversal): just the source table, no segments — e.g. \`".invoice_line_items"\`
55
55
 
56
56
  Examples:
57
- | relation string | type | meaning |
58
- |---|---|---|
59
- | \`.trips.packing_items$trip_id\` | ChildList | all packing_items for a trip |
60
- | \`.packing_items.trip_id\` | ParentShow | the trip that owns a packing_item |
61
- | \`.artists.artist_plays_on_album$artist.album\` | ChildList | albums via join table |
62
- | \`.users.orders$user_id.order_lines$order_id\` | RelationPath | multi-level |
57
+ | relation string | meaning |
58
+ |---|---|
59
+ | \`.invoice_line_items\` | same-table link (no FK traversal) |
60
+ | \`.trips.packing_items$trip_id\` | all packing_items for a trip |
61
+ | \`.packing_items.trip_id\` | the trip that owns a packing_item |
62
+ | \`.artists.artist_plays_on_album$artist.album\` | albums via join table |
63
+ | \`.users.orders$user_id.order_lines$order_id\` | multi-level |
63
64
 
64
65
  ---
65
66
 
66
- ### Legacy format (you may encounter this in existing configs do not generate it)
67
+ ### Legacy format — read-only, never generate
67
68
 
68
- The type and path are encoded together inside the \`view\` field as a colon-prefixed string.
69
- There is no separate \`relation\` field.
69
+ Any \`view\` field value that contains a colon (e.g. \`"ChildList:trips_list.packing_items.trip_id"\`,
70
+ \`"Own:viewname"\`, \`"ParentShow:viewname.table.fkfield"\`) is legacy. You may encounter it in
71
+ existing configs. Parse it to understand the relation, then always write back in the new format.
70
72
 
71
- | legacy view field | equivalent new relation |
73
+ | legacy view field | new format equivalent |
72
74
  |---|---|
73
- | \`"Own:viewname"\` | \`view: "viewname"\`, no relation |
75
+ | \`"Own:viewname"\` | \`relation: ".sourcetable"\` |
76
+ | \`"Independent:viewname"\` | no \`relation\` field needed |
74
77
  | \`"ParentShow:viewname.table.fkfield"\` | \`relation: ".sourcetable.fkfield"\` |
75
78
  | \`"ChildList:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
76
79
  | \`"OneToOneShow:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
77
- | \`"Independent:viewname"\` | \`view: "viewname"\`, no relation |
78
-
79
- When you read an existing view config and see a legacy \`view\` value like \`"ChildList:trips_list.packing_items.trip_id"\`, parse it as: type=ChildList, view=trips_list, relation=.sourcetable.packing_items$trip_id. When writing back, convert to the new format.
80
80
 
81
81
  ---
82
82
 
83
83
  ### Using get_relation_paths
84
84
 
85
- Call it **once** with all source_table/target_view pairs you need — do not make separate calls per pair. The tool returns paths in the new RelationPath format; always write back in the new format, even if you read legacy strings from an existing config.
85
+ Call it **once** with all source_table/target_view pairs you need. The tool always returns new-format
86
+ path strings — use them as the \`relation\` field directly.
86
87
 
87
- When multiple paths are returned for one pair, pick by matching the relation type to what the target view is meant to show:
88
- - **ChildList** target view shows multiple rows belonging to the current row (e.g. packing items for a trip).
89
- - **ParentShow** target view shows the single parent the current row belongs to (e.g. the trip for a packing item).
88
+ **Depth escalation:** Start with \`max_depth=2\`. After receiving results, analyse each pair: does the
89
+ result contain a path that matches the intended relation type? If yes, use it. If a pair has no
90
+ suitable path (no paths at all, or none of the right type), call again with \`max_depth=4\`, then
91
+ \`max_depth=6\` if still none. Do not escalate just because multiple paths are listed.
92
+
93
+ Selecting among returned paths:
94
+ - **ChildList** — target view shows multiple rows belonging to the current row.
95
+ - **ParentShow** — target view shows the single parent the current row belongs to.
90
96
  - **OneToOneShow** — exactly one related child row via a unique FK.
91
- - **Own** — target view is on the same table (no FK traversal needed).
92
- - If multiple paths of the same type exist (e.g. a table has two FKs pointing to the same target), pick the one whose FK field name best matches the semantic relationship in the task.
97
+ - **Own** — same table, no FK traversal. Relation string is just \`.sourcetable\`.
98
+ - If multiple paths of the same type exist, pick the one whose FK field name best matches the task.
93
99
  - Prefer shorter paths (fewer segments) unless a longer one is clearly more appropriate.
94
100
  `;
95
101
 
96
102
  const typeToLabel = (type) => {
97
103
  if (type === RelationType.OWN)
98
- return "Own – source and target are the same table (no relation needed)";
104
+ return "Own – source and target are the same table. Use this relation string as-is (no extra segments after the table name).";
99
105
  if (type === RelationType.INDEPENDENT)
100
106
  return "Independent – no FK relationship exists";
101
107
  if (type === RelationType.PARENT_SHOW)
@@ -113,10 +119,19 @@ const typeToLabel = (type) => {
113
119
  * @param {{ tables, views }} schemaData pre-fetched via build_schema_data()
114
120
  * @returns {Array<Relation>} raw Relation objects from RelationsFinder
115
121
  */
116
- function getRelationPaths(sourceTableName, targetViewName, schemaData) {
122
+ function getRelationPaths(
123
+ sourceTableName,
124
+ targetViewName,
125
+ schemaData,
126
+ maxDepth = 6
127
+ ) {
117
128
  if (!schemaData) return [];
118
129
  try {
119
- const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
130
+ const finder = new RelationsFinder(
131
+ schemaData.tables,
132
+ schemaData.views,
133
+ maxDepth
134
+ );
120
135
  return finder.findRelations(sourceTableName, targetViewName, []);
121
136
  } catch {
122
137
  return [];
@@ -130,13 +145,22 @@ function getRelationPaths(sourceTableName, targetViewName, schemaData) {
130
145
  * @param {{ tables, views }} schemaData
131
146
  * @returns {Array<string>} one formatted result string per pair
132
147
  */
133
- function getRelationPathsForPairs(pairs, schemaData) {
134
- if (!schemaData) return pairs.map(({ source_table, target_view }) =>
135
- formatRelationPathResult(source_table, target_view, { error: "Schema data unavailable" })
148
+ function getRelationPathsForPairs(pairs, schemaData, maxDepth = 6) {
149
+ if (!schemaData)
150
+ return pairs.map(({ source_table, target_view }) =>
151
+ formatRelationPathResult(source_table, target_view, {
152
+ error: "Schema data unavailable",
153
+ })
154
+ );
155
+ const finder = new RelationsFinder(
156
+ schemaData.tables,
157
+ schemaData.views,
158
+ maxDepth
136
159
  );
137
- const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
138
160
  return pairs.map(({ source_table, target_view }) => {
139
- const targetView = (schemaData.views || []).find((v) => v.name === target_view);
161
+ const targetView = (schemaData.views || []).find(
162
+ (v) => v.name === target_view
163
+ );
140
164
  if (!targetView)
141
165
  return formatRelationPathResult(source_table, target_view, {
142
166
  error: `View "${target_view}" not found in current schema`,
@@ -198,8 +222,11 @@ const GET_RELATION_PATHS_FUNCTION = {
198
222
  name: "get_relation_paths",
199
223
  description:
200
224
  "Get all valid relation path strings for one or more source_table/target_view pairs. " +
201
- "Call this ONCE with all pairs you need before setting any 'relation' property on " +
202
- "view_link columns or embedded view (type 'view') segments.",
225
+ "Call this before setting any 'relation' property on view_link columns or embedded view segments. " +
226
+ "Always start with max_depth=2 to keep the result compact. " +
227
+ "After receiving the results, analyse whether each pair has a suitable path for its intended relation type. " +
228
+ "If a pair has no suitable path, call again with max_depth=4, then max_depth=6 if still none. " +
229
+ "Stop as soon as every pair has a suitable path.",
203
230
  parameters: {
204
231
  type: "object",
205
232
  required: ["pairs"],
@@ -223,6 +250,12 @@ const GET_RELATION_PATHS_FUNCTION = {
223
250
  },
224
251
  },
225
252
  },
253
+ max_depth: {
254
+ type: "integer",
255
+ description:
256
+ "Maximum join depth to search. Start with 2. Escalate to 4, then 6, only if a pair has no suitable path in the current results — not just because paths exist, but because none match the intended relation type.",
257
+ default: 2,
258
+ },
226
259
  },
227
260
  },
228
261
  };
@@ -73,6 +73,7 @@ ${ds.join("\n")}`);
73
73
  },
74
74
  isAsync: true,
75
75
  description: "Return a standard prompt for writing code",
76
+ hidden: true,
76
77
  arguments: [
77
78
  {
78
79
  name: "options",
package/user-copilot.js CHANGED
@@ -933,5 +933,7 @@ module.exports = {
933
933
  get_state_fields,
934
934
  tableless: true,
935
935
  run,
936
+ deprecated: true,
937
+ description: "Use Agents from the agent module instead",
936
938
  routes: { interact, delprevrun },
937
939
  };
package/workflow-gen.js CHANGED
@@ -41,5 +41,6 @@ module.exports = {
41
41
  },
42
42
  isAsync: true,
43
43
  description: "Generate a workflow",
44
+ hidden: true,
44
45
  arguments: [{ name: "description", type: "String" }],
45
46
  };