@objectstack/service-ai 6.0.0 → 6.2.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.
package/dist/index.cjs CHANGED
@@ -848,16 +848,20 @@ var init_metadata_tools = __esm({
848
848
  // src/index.ts
849
849
  var index_exports = {};
850
850
  __export(index_exports, {
851
+ ACTIONS_EXECUTOR_SKILL: () => ACTIONS_EXECUTOR_SKILL,
851
852
  AIService: () => AIService,
852
853
  AIServicePlugin: () => AIServicePlugin,
853
854
  AgentRuntime: () => AgentRuntime,
854
855
  AiConversationObject: () => AiConversationObject,
855
856
  AiMessageObject: () => AiMessageObject,
856
857
  AiTraceObject: () => AiTraceObject,
858
+ AiTraceView: () => AiTraceView,
857
859
  DATA_CHAT_AGENT: () => DATA_CHAT_AGENT,
860
+ DATA_EXPLORER_SKILL: () => DATA_EXPLORER_SKILL,
858
861
  DATA_TOOL_DEFINITIONS: () => DATA_TOOL_DEFINITIONS,
859
862
  InMemoryConversationService: () => InMemoryConversationService,
860
863
  METADATA_ASSISTANT_AGENT: () => METADATA_ASSISTANT_AGENT,
864
+ METADATA_AUTHORING_SKILL: () => METADATA_AUTHORING_SKILL,
861
865
  METADATA_TOOL_DEFINITIONS: () => METADATA_TOOL_DEFINITIONS,
862
866
  MemoryLLMAdapter: () => MemoryLLMAdapter,
863
867
  ModelRegistry: () => ModelRegistry,
@@ -870,6 +874,9 @@ __export(index_exports, {
870
874
  SkillRegistry: () => SkillRegistry,
871
875
  ToolRegistry: () => ToolRegistry,
872
876
  VercelLLMAdapter: () => VercelLLMAdapter,
877
+ actionSkipReason: () => actionSkipReason,
878
+ actionToToolDefinition: () => actionToToolDefinition,
879
+ actionToolName: () => actionToolName,
873
880
  addFieldTool: () => addFieldTool,
874
881
  buildAIRoutes: () => buildAIRoutes,
875
882
  buildAgentRoutes: () => buildAgentRoutes,
@@ -889,6 +896,7 @@ __export(index_exports, {
889
896
  listObjectsTool: () => listObjectsTool,
890
897
  listPackagesTool: () => listPackagesTool,
891
898
  modifyFieldTool: () => modifyFieldTool,
899
+ registerActionsAsTools: () => registerActionsAsTools,
892
900
  registerDataTools: () => registerDataTools,
893
901
  registerMetadataTools: () => registerMetadataTools,
894
902
  registerPackageTools: () => registerPackageTools,
@@ -908,8 +916,112 @@ var MemoryLLMAdapter = class {
908
916
  async chat(messages, options) {
909
917
  const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
910
918
  const userContent = lastUserMessage?.content;
911
- const text = typeof userContent === "string" ? userContent : "(complex content)";
912
- const content = lastUserMessage ? `[memory] ${text}` : "[memory] (no user message)";
919
+ const userText = typeof userContent === "string" ? userContent : "(complex content)";
920
+ const tools = options?.tools;
921
+ const hasQueryDataTool = Array.isArray(tools) && tools.some((t) => t?.name === "query_data");
922
+ const alreadyCalledQueryData = messages.some(
923
+ (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some((c) => c?.toolName === "query_data")
924
+ );
925
+ const alreadyCalledAction = messages.some(
926
+ (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some(
927
+ (c) => typeof c?.toolName === "string" && c.toolName.startsWith("action_")
928
+ )
929
+ );
930
+ if (Array.isArray(tools) && !alreadyCalledAction && lastUserMessage) {
931
+ const actionTools = tools.filter((t) => typeof t?.name === "string" && t.name.startsWith("action_"));
932
+ const chosen = pickActionTool(userText, actionTools);
933
+ if (chosen) {
934
+ const recordId = extractRecordIdFromMessages(messages, userText);
935
+ if (recordId) {
936
+ const toolCallId = `memory_tc_${Date.now().toString(36)}`;
937
+ return {
938
+ content: "",
939
+ model: options?.model ?? "memory",
940
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
941
+ toolCalls: [
942
+ {
943
+ type: "tool-call",
944
+ toolCallId,
945
+ toolName: chosen.name,
946
+ input: { recordId }
947
+ }
948
+ ]
949
+ };
950
+ }
951
+ }
952
+ }
953
+ if (hasQueryDataTool && !alreadyCalledQueryData && lastUserMessage) {
954
+ const toolCallId = `memory_tc_${Date.now().toString(36)}`;
955
+ return {
956
+ content: "",
957
+ model: options?.model ?? "memory",
958
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
959
+ toolCalls: [
960
+ {
961
+ type: "tool-call",
962
+ toolCallId,
963
+ toolName: "query_data",
964
+ input: { request: userText }
965
+ }
966
+ ]
967
+ };
968
+ }
969
+ if (alreadyCalledAction) {
970
+ const lastTool = [...messages].reverse().find((m) => m.role === "tool");
971
+ const part = Array.isArray(lastTool?.content) ? lastTool.content.find((c) => typeof c?.toolName === "string" && c.toolName.startsWith("action_")) : void 0;
972
+ const raw = part?.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part?.result;
973
+ let payload = {};
974
+ if (typeof raw === "string") {
975
+ try {
976
+ payload = JSON.parse(raw);
977
+ } catch {
978
+ }
979
+ } else if (raw && typeof raw === "object") {
980
+ payload = raw;
981
+ }
982
+ if (payload.error) {
983
+ return {
984
+ content: `[memory] action ${payload.action ?? ""} failed: ${payload.error}`,
985
+ model: options?.model ?? "memory",
986
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
987
+ };
988
+ }
989
+ return {
990
+ content: `[memory] ${payload.message ?? "Action executed."} (${payload.action ?? "action"})`,
991
+ model: options?.model ?? "memory",
992
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
993
+ };
994
+ }
995
+ if (alreadyCalledQueryData) {
996
+ const lastTool = [...messages].reverse().find((m) => m.role === "tool");
997
+ const part = Array.isArray(lastTool?.content) ? lastTool.content.find((c) => c?.toolName === "query_data") : void 0;
998
+ let payload = {};
999
+ const raw = part?.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part?.result;
1000
+ if (typeof raw === "string") {
1001
+ try {
1002
+ payload = JSON.parse(raw);
1003
+ } catch {
1004
+ payload = {};
1005
+ }
1006
+ } else if (raw && typeof raw === "object") {
1007
+ payload = raw;
1008
+ }
1009
+ if (payload.error) {
1010
+ return {
1011
+ content: `[memory] query_data failed: ${payload.error}`,
1012
+ model: options?.model ?? "memory",
1013
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1014
+ };
1015
+ }
1016
+ const records = payload.records ?? [];
1017
+ const count = payload.count ?? records.length;
1018
+ return {
1019
+ content: `[memory] Found ${count} record${count === 1 ? "" : "s"} for "${userText}".`,
1020
+ model: options?.model ?? "memory",
1021
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1022
+ };
1023
+ }
1024
+ const content = lastUserMessage ? `[memory] ${userText}` : "[memory] (no user message)";
913
1025
  return {
914
1026
  content,
915
1027
  model: options?.model ?? "memory",
@@ -961,23 +1073,42 @@ var MemoryLLMAdapter = class {
961
1073
  */
962
1074
  async generateObject(messages, schema, options) {
963
1075
  const sys = messages.filter((m) => m.role === "system").map((m) => typeof m.content === "string" ? m.content : "").join("\n");
964
- const headerRe = /^###\s+([a-z0-9_]+)\b/gim;
1076
+ const headerRe = /^###\s+([a-z0-9_]+)(?:\s+—\s+([^\n]+))?/gim;
965
1077
  const candidates = [];
966
1078
  for (const match of sys.matchAll(headerRe)) {
967
- if (match[1]) candidates.push(match[1]);
1079
+ const machineName = match[1];
1080
+ if (!machineName) continue;
1081
+ const aliasText = match[2] ?? "";
1082
+ const aliasTokens = /* @__PURE__ */ new Set();
1083
+ for (const t of machineName.split(/[^a-z0-9]+/)) {
1084
+ if (t) aliasTokens.add(t);
1085
+ }
1086
+ for (const t of aliasText.toLowerCase().split(/[^a-z0-9]+/)) {
1087
+ if (t) aliasTokens.add(t);
1088
+ }
1089
+ for (const t of [...aliasTokens]) {
1090
+ if (t.length > 3 && t.endsWith("s")) aliasTokens.add(t.slice(0, -1));
1091
+ }
1092
+ candidates.push({ name: machineName, aliasTokens });
968
1093
  }
969
1094
  const lastUser = [...messages].reverse().find((m) => m.role === "user");
970
1095
  const userText = typeof lastUser?.content === "string" ? lastUser.content.toLowerCase() : "";
971
1096
  const userTokens = new Set(
972
1097
  userText.split(/[^a-z0-9_]+/).filter((t) => t.length > 1)
973
1098
  );
974
- let chosen = candidates[0];
1099
+ for (const t of [...userTokens]) {
1100
+ if (t.length > 3 && t.endsWith("s")) userTokens.add(t.slice(0, -1));
1101
+ }
1102
+ let chosen = candidates[0]?.name;
975
1103
  let bestScore = -1;
976
- for (const name of candidates) {
977
- const score = name.split(/[^a-z0-9]+/).reduce((acc, tok) => acc + (tok && userTokens.has(tok) ? 1 : 0), 0);
1104
+ for (const cand of candidates) {
1105
+ let score = 0;
1106
+ for (const tok of cand.aliasTokens) {
1107
+ if (userTokens.has(tok)) score += 1;
1108
+ }
978
1109
  if (score > bestScore) {
979
1110
  bestScore = score;
980
- chosen = name;
1111
+ chosen = cand.name;
981
1112
  }
982
1113
  }
983
1114
  const attempts = [];
@@ -998,6 +1129,103 @@ var MemoryLLMAdapter = class {
998
1129
  );
999
1130
  }
1000
1131
  };
1132
+ function pickActionTool(userText, actionTools) {
1133
+ if (actionTools.length === 0 || !userText) return null;
1134
+ const userTokens = new Set(
1135
+ userText.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2)
1136
+ );
1137
+ const ACTION_VERBS = /* @__PURE__ */ new Set([
1138
+ "complete",
1139
+ "finish",
1140
+ "done",
1141
+ "close",
1142
+ "start",
1143
+ "begin",
1144
+ "resume",
1145
+ "clone",
1146
+ "copy",
1147
+ "duplicate",
1148
+ "cancel",
1149
+ "abort",
1150
+ "archive",
1151
+ "restore",
1152
+ "approve",
1153
+ "reject",
1154
+ "assign",
1155
+ "unassign",
1156
+ "export",
1157
+ "import",
1158
+ "send",
1159
+ "notify",
1160
+ "publish",
1161
+ "unpublish",
1162
+ "mark",
1163
+ "delete",
1164
+ "remove",
1165
+ "purge",
1166
+ "destroy",
1167
+ "erase"
1168
+ ]);
1169
+ const hasActionVerb = [...userTokens].some((t) => ACTION_VERBS.has(t));
1170
+ if (!hasActionVerb) return null;
1171
+ let best = null;
1172
+ let bestScore = 0;
1173
+ for (const tool of actionTools) {
1174
+ const nameTokens = tool.name.replace(/^action_/, "").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2);
1175
+ let score = 0;
1176
+ for (const tok of nameTokens) {
1177
+ if (!userTokens.has(tok)) continue;
1178
+ score += ACTION_VERBS.has(tok) ? 3 : 1;
1179
+ }
1180
+ if (score > bestScore) {
1181
+ bestScore = score;
1182
+ best = tool;
1183
+ }
1184
+ }
1185
+ return bestScore >= 3 ? best : null;
1186
+ }
1187
+ function extractRecordIdFromMessages(messages, userText) {
1188
+ const userTokens = userText.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2);
1189
+ for (let i = messages.length - 1; i >= 0; i--) {
1190
+ const m = messages[i];
1191
+ if (m.role !== "tool" || !Array.isArray(m.content)) continue;
1192
+ const parts = m.content;
1193
+ for (const part of parts) {
1194
+ if (part?.toolName !== "query_data") continue;
1195
+ const raw = part.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part.result;
1196
+ let payload = {};
1197
+ if (typeof raw === "string") {
1198
+ try {
1199
+ payload = JSON.parse(raw);
1200
+ } catch {
1201
+ }
1202
+ } else if (raw && typeof raw === "object") {
1203
+ payload = raw;
1204
+ }
1205
+ const records = payload.records ?? [];
1206
+ if (records.length === 0) continue;
1207
+ let bestId;
1208
+ let bestScore = -1;
1209
+ for (const rec of records) {
1210
+ if (!rec || typeof rec !== "object") continue;
1211
+ const id = rec.id;
1212
+ if (typeof id !== "string" && typeof id !== "number") continue;
1213
+ const hay = Object.values(rec).filter((v) => typeof v === "string").join(" ").toLowerCase();
1214
+ const hayTokens = hay.split(/[^a-z0-9]+/).filter(Boolean);
1215
+ let score = 0;
1216
+ for (const ut of userTokens) {
1217
+ if (hayTokens.includes(ut)) score += 1;
1218
+ }
1219
+ if (score > bestScore) {
1220
+ bestScore = score;
1221
+ bestId = String(id);
1222
+ }
1223
+ }
1224
+ return bestId ?? String(records[0].id);
1225
+ }
1226
+ }
1227
+ return void 0;
1228
+ }
1001
1229
 
1002
1230
  // src/tools/tool-registry.ts
1003
1231
  var ToolRegistry = class {
@@ -1241,12 +1469,20 @@ function finishPart(result) {
1241
1469
  }
1242
1470
  var _AIService = class _AIService {
1243
1471
  constructor(config = {}) {
1472
+ /**
1473
+ * Map of tool-name → dispatcher used to re-run an approved pending
1474
+ * action. Populated by `registerActionsAsTools()` when action
1475
+ * approval is enabled. Kept private because callers should go
1476
+ * through `approvePendingAction()`.
1477
+ */
1478
+ this.pendingDispatchers = /* @__PURE__ */ new Map();
1244
1479
  this.adapter = config.adapter ?? new MemoryLLMAdapter();
1245
1480
  this.logger = config.logger ?? (0, import_core.createLogger)({ level: "info", format: "pretty" });
1246
1481
  this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
1247
1482
  this.conversationService = config.conversationService ?? new InMemoryConversationService();
1248
1483
  this.modelRegistry = config.modelRegistry;
1249
1484
  this.traceRecorder = config.traceRecorder ?? new NullTraceRecorder();
1485
+ this.dataEngine = config.dataEngine;
1250
1486
  this.logger.info(
1251
1487
  `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}, models=${this.modelRegistry?.size ?? 0}`
1252
1488
  );
@@ -1361,6 +1597,13 @@ var _AIService = class _AIService {
1361
1597
  * maximum number of iterations (`maxIterations`) is reached.
1362
1598
  */
1363
1599
  async chatWithTools(messages, options) {
1600
+ return this.instrument(
1601
+ "chat_with_tools",
1602
+ options,
1603
+ () => this.chatWithToolsImpl(messages, options)
1604
+ );
1605
+ }
1606
+ async chatWithToolsImpl(messages, options) {
1364
1607
  const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
1365
1608
  const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
1366
1609
  const registeredTools = this.toolRegistry.getAll();
@@ -1513,11 +1756,144 @@ var _AIService = class _AIService {
1513
1756
  yield textDeltaPart("stream", result.content);
1514
1757
  yield finishPart(result);
1515
1758
  }
1759
+ // ── HITL: pending-action queue ─────────────────────────────────
1760
+ /**
1761
+ * Register a dispatcher callback for a tool. Called by
1762
+ * `registerActionsAsTools()` when action approval is enabled so the
1763
+ * approval handler can re-run the exact same code path the LLM
1764
+ * would have triggered.
1765
+ */
1766
+ registerPendingActionDispatcher(toolName, dispatch) {
1767
+ this.pendingDispatchers.set(toolName, dispatch);
1768
+ }
1769
+ async proposePendingAction(input) {
1770
+ if (!this.dataEngine) {
1771
+ throw new Error("proposePendingAction requires a dataEngine \u2014 wire it via AIServiceConfig.");
1772
+ }
1773
+ const id = `pa_${cryptoRandomId()}`;
1774
+ const row = {
1775
+ id,
1776
+ conversation_id: input.conversationId ?? null,
1777
+ message_id: input.messageId ?? null,
1778
+ object_name: input.objectName,
1779
+ action_name: input.actionName,
1780
+ tool_name: input.toolName,
1781
+ tool_input: JSON.stringify(input.toolInput ?? {}),
1782
+ status: "pending",
1783
+ proposed_by: input.proposedBy ?? "ai_agent",
1784
+ proposed_at: (/* @__PURE__ */ new Date()).toISOString()
1785
+ };
1786
+ await this.dataEngine.insert("ai_pending_actions", row);
1787
+ this.logger.info(
1788
+ `[AI] pending action proposed: ${id} (${input.toolName} on ${input.objectName})`
1789
+ );
1790
+ return { id };
1791
+ }
1792
+ async approvePendingAction(id, actorId) {
1793
+ if (!this.dataEngine) {
1794
+ throw new Error("approvePendingAction requires a dataEngine.");
1795
+ }
1796
+ const row = await this.loadPendingRow(id);
1797
+ if (row.status !== "pending") {
1798
+ throw new Error(`pending action ${id} is already ${row.status}`);
1799
+ }
1800
+ const dispatch = this.pendingDispatchers.get(row.tool_name);
1801
+ if (!dispatch) {
1802
+ throw new Error(
1803
+ `no dispatcher registered for tool '${row.tool_name}' \u2014 was the AI plugin restarted without re-registering actions?`
1804
+ );
1805
+ }
1806
+ await this.dataEngine.update(
1807
+ "ai_pending_actions",
1808
+ {
1809
+ id,
1810
+ status: "approved",
1811
+ decided_by: actorId,
1812
+ decided_at: (/* @__PURE__ */ new Date()).toISOString()
1813
+ },
1814
+ { where: { id } }
1815
+ );
1816
+ let parsed = {};
1817
+ try {
1818
+ parsed = row.tool_input ? JSON.parse(row.tool_input) : {};
1819
+ } catch {
1820
+ parsed = {};
1821
+ }
1822
+ try {
1823
+ const out = await dispatch(parsed);
1824
+ await this.dataEngine.update(
1825
+ "ai_pending_actions",
1826
+ { id, status: "executed", result: JSON.stringify(out ?? null) },
1827
+ { where: { id } }
1828
+ );
1829
+ this.logger.info(`[AI] pending action ${id} executed by ${actorId}`);
1830
+ return { status: "executed", result: out };
1831
+ } catch (err) {
1832
+ const msg = err instanceof Error ? err.message : String(err);
1833
+ await this.dataEngine.update(
1834
+ "ai_pending_actions",
1835
+ { id, status: "failed", error: msg },
1836
+ { where: { id } }
1837
+ );
1838
+ this.logger.warn(`[AI] pending action ${id} failed after approval: ${msg}`);
1839
+ return { status: "failed", error: msg };
1840
+ }
1841
+ }
1842
+ async rejectPendingAction(id, actorId, reason) {
1843
+ if (!this.dataEngine) {
1844
+ throw new Error("rejectPendingAction requires a dataEngine.");
1845
+ }
1846
+ const row = await this.loadPendingRow(id);
1847
+ if (row.status !== "pending") {
1848
+ throw new Error(`pending action ${id} is already ${row.status}`);
1849
+ }
1850
+ await this.dataEngine.update(
1851
+ "ai_pending_actions",
1852
+ {
1853
+ id,
1854
+ status: "rejected",
1855
+ decided_by: actorId,
1856
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
1857
+ rejection_reason: reason ?? null
1858
+ },
1859
+ { where: { id } }
1860
+ );
1861
+ this.logger.info(`[AI] pending action ${id} rejected by ${actorId}`);
1862
+ }
1863
+ async listPendingActions(filter) {
1864
+ if (!this.dataEngine) return [];
1865
+ const where = {};
1866
+ if (filter?.status) {
1867
+ where.status = Array.isArray(filter.status) ? { in: filter.status } : filter.status;
1868
+ }
1869
+ if (filter?.conversationId) where.conversation_id = filter.conversationId;
1870
+ if (filter?.objectName) where.object_name = filter.objectName;
1871
+ const rows = await this.dataEngine.find("ai_pending_actions", {
1872
+ where,
1873
+ limit: filter?.limit ?? 100,
1874
+ orderBy: [{ field: "proposed_at", order: "desc" }]
1875
+ });
1876
+ return rows;
1877
+ }
1878
+ async loadPendingRow(id) {
1879
+ const rows = await this.dataEngine.find("ai_pending_actions", {
1880
+ where: { id },
1881
+ limit: 1
1882
+ });
1883
+ const row = rows[0];
1884
+ if (!row) throw new Error(`pending action ${id} not found`);
1885
+ return row;
1886
+ }
1516
1887
  };
1517
1888
  // ── Tool Call Loop ────────────────────────────────────────────
1518
1889
  /** Default maximum iterations for the tool call loop. */
1519
1890
  _AIService.DEFAULT_MAX_ITERATIONS = 10;
1520
1891
  var AIService = _AIService;
1892
+ function cryptoRandomId() {
1893
+ const g = globalThis;
1894
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID();
1895
+ return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1896
+ }
1521
1897
 
1522
1898
  // src/stream/vercel-stream-encoder.ts
1523
1899
  function sse(data) {
@@ -2402,6 +2778,132 @@ function buildToolRoutes(aiService, logger) {
2402
2778
  ];
2403
2779
  }
2404
2780
 
2781
+ // src/routes/pending-action-routes.ts
2782
+ function buildPendingActionRoutes(aiService, logger) {
2783
+ const supported = typeof aiService.listPendingActions === "function" && typeof aiService.approvePendingAction === "function" && typeof aiService.rejectPendingAction === "function";
2784
+ if (!supported) {
2785
+ logger.warn(
2786
+ "[AI] HITL pending-action methods not implemented on AI service \u2014 routes return 501."
2787
+ );
2788
+ }
2789
+ const notImpl = () => ({
2790
+ status: 501,
2791
+ body: { error: "Pending-action queue not available (dataEngine not wired)" }
2792
+ });
2793
+ return [
2794
+ // ── List pending actions ───────────────────────────────────────
2795
+ {
2796
+ method: "GET",
2797
+ path: "/api/v1/ai/pending-actions",
2798
+ description: "List pending actions in the HITL approval queue",
2799
+ auth: true,
2800
+ permissions: ["ai:read"],
2801
+ handler: async (req) => {
2802
+ if (!supported) return notImpl();
2803
+ try {
2804
+ const query = req.query ?? {};
2805
+ const status = typeof query.status === "string" ? query.status : void 0;
2806
+ const conversationId = typeof query.conversationId === "string" ? query.conversationId : void 0;
2807
+ const limitRaw = query.limit;
2808
+ const limit = typeof limitRaw === "string" ? Number(limitRaw) : void 0;
2809
+ const rows = await aiService.listPendingActions({
2810
+ status,
2811
+ conversationId,
2812
+ limit: Number.isFinite(limit) ? limit : void 0
2813
+ });
2814
+ return { status: 200, body: { items: rows, total: rows.length } };
2815
+ } catch (err) {
2816
+ logger.error(
2817
+ "[AI Route] /pending-actions list error",
2818
+ err instanceof Error ? err : void 0
2819
+ );
2820
+ return { status: 500, body: { error: "Failed to list pending actions" } };
2821
+ }
2822
+ }
2823
+ },
2824
+ // ── Get a single pending action ────────────────────────────────
2825
+ {
2826
+ method: "GET",
2827
+ path: "/api/v1/ai/pending-actions/:id",
2828
+ description: "Get a single pending action by id",
2829
+ auth: true,
2830
+ permissions: ["ai:read"],
2831
+ handler: async (req) => {
2832
+ if (!supported) return notImpl();
2833
+ const id = req.params?.id;
2834
+ if (!id) return { status: 400, body: { error: "id is required" } };
2835
+ try {
2836
+ const rows = await aiService.listPendingActions({});
2837
+ const found = rows.find((r) => r.id === id);
2838
+ if (!found) return { status: 404, body: { error: `Pending action ${id} not found` } };
2839
+ return { status: 200, body: found };
2840
+ } catch (err) {
2841
+ logger.error(
2842
+ "[AI Route] /pending-actions/:id error",
2843
+ err instanceof Error ? err : void 0
2844
+ );
2845
+ return { status: 500, body: { error: "Failed to load pending action" } };
2846
+ }
2847
+ }
2848
+ },
2849
+ // ── Approve & execute ──────────────────────────────────────────
2850
+ {
2851
+ method: "POST",
2852
+ path: "/api/v1/ai/pending-actions/:id/approve",
2853
+ description: "Approve a pending action and execute it immediately",
2854
+ auth: true,
2855
+ permissions: ["ai:approve"],
2856
+ handler: async (req) => {
2857
+ if (!supported) return notImpl();
2858
+ const id = req.params?.id;
2859
+ if (!id) return { status: 400, body: { error: "id is required" } };
2860
+ const actorId = req.user?.id ?? "system";
2861
+ try {
2862
+ const outcome = await aiService.approvePendingAction(id, actorId);
2863
+ const httpStatus = outcome.status === "executed" ? 200 : 500;
2864
+ return { status: httpStatus, body: outcome };
2865
+ } catch (err) {
2866
+ const msg = err instanceof Error ? err.message : String(err);
2867
+ logger.error("[AI Route] /pending-actions/:id/approve error", err instanceof Error ? err : void 0);
2868
+ if (/not found/i.test(msg)) return { status: 404, body: { error: msg } };
2869
+ if (/already|not pending|no dispatcher/i.test(msg)) {
2870
+ return { status: 409, body: { error: msg } };
2871
+ }
2872
+ return { status: 500, body: { error: msg } };
2873
+ }
2874
+ }
2875
+ },
2876
+ // ── Reject ─────────────────────────────────────────────────────
2877
+ {
2878
+ method: "POST",
2879
+ path: "/api/v1/ai/pending-actions/:id/reject",
2880
+ description: "Reject a pending action (will not be executed)",
2881
+ auth: true,
2882
+ permissions: ["ai:approve"],
2883
+ handler: async (req) => {
2884
+ if (!supported) return notImpl();
2885
+ const id = req.params?.id;
2886
+ if (!id) return { status: 400, body: { error: "id is required" } };
2887
+ const actorId = req.user?.id ?? "system";
2888
+ const body = req.body ?? {};
2889
+ const reason = typeof body.reason === "string" ? body.reason : void 0;
2890
+ try {
2891
+ await aiService.rejectPendingAction(id, actorId, reason);
2892
+ return { status: 200, body: { status: "rejected", id } };
2893
+ } catch (err) {
2894
+ const msg = err instanceof Error ? err.message : String(err);
2895
+ logger.error("[AI Route] /pending-actions/:id/reject error", err instanceof Error ? err : void 0);
2896
+ if (/not found/i.test(msg)) return { status: 404, body: { error: msg } };
2897
+ if (/already|not pending/i.test(msg)) {
2898
+ return { status: 409, body: { error: msg } };
2899
+ }
2900
+ return { status: 500, body: { error: msg } };
2901
+ }
2902
+ }
2903
+ }
2904
+ ];
2905
+ }
2906
+
2405
2907
  // src/conversation/objectql-conversation-service.ts
2406
2908
  var import_node_crypto2 = require("crypto");
2407
2909
  var CONVERSATIONS_OBJECT = "ai_conversations";
@@ -2872,6 +3374,438 @@ var AiTraceObject = import_data3.ObjectSchema.create({
2872
3374
  }
2873
3375
  });
2874
3376
 
3377
+ // src/objects/ai-pending-action.object.ts
3378
+ var import_data4 = require("@objectstack/spec/data");
3379
+ var AiPendingActionObject = import_data4.ObjectSchema.create({
3380
+ name: "ai_pending_actions",
3381
+ label: "AI Pending Action",
3382
+ pluralLabel: "AI Pending Actions",
3383
+ icon: "shield-check",
3384
+ isSystem: true,
3385
+ description: "Queue of AI-proposed action invocations awaiting human approval",
3386
+ fields: {
3387
+ id: import_data4.Field.text({
3388
+ label: "Request ID",
3389
+ required: true,
3390
+ readonly: true
3391
+ }),
3392
+ conversation_id: import_data4.Field.lookup("ai_conversations", {
3393
+ label: "Conversation",
3394
+ required: false,
3395
+ description: "Conversation that produced this proposal, if any"
3396
+ }),
3397
+ message_id: import_data4.Field.lookup("ai_messages", {
3398
+ label: "Message",
3399
+ required: false,
3400
+ description: "Assistant message containing the proposed tool call"
3401
+ }),
3402
+ object_name: import_data4.Field.text({
3403
+ label: "Object",
3404
+ required: true,
3405
+ maxLength: 128,
3406
+ description: 'Target object name (e.g. "task")'
3407
+ }),
3408
+ action_name: import_data4.Field.text({
3409
+ label: "Action",
3410
+ required: true,
3411
+ maxLength: 128,
3412
+ description: 'Declarative action name (e.g. "delete_task")'
3413
+ }),
3414
+ tool_name: import_data4.Field.text({
3415
+ label: "Tool",
3416
+ required: true,
3417
+ maxLength: 128,
3418
+ description: 'AI tool name exposed to the LLM (e.g. "action_delete_task")'
3419
+ }),
3420
+ tool_input: import_data4.Field.textarea({
3421
+ label: "Tool Input",
3422
+ required: true,
3423
+ description: "JSON-serialised tool arguments the LLM passed"
3424
+ }),
3425
+ status: import_data4.Field.select({
3426
+ label: "Status",
3427
+ required: true,
3428
+ defaultValue: "pending",
3429
+ options: [
3430
+ { label: "Pending Approval", value: "pending" },
3431
+ { label: "Approved (queued)", value: "approved" },
3432
+ { label: "Executed", value: "executed" },
3433
+ { label: "Failed", value: "failed" },
3434
+ { label: "Rejected", value: "rejected" }
3435
+ ]
3436
+ }),
3437
+ result: import_data4.Field.textarea({
3438
+ label: "Execution Result",
3439
+ required: false,
3440
+ description: "JSON-serialised result from the action when executed"
3441
+ }),
3442
+ error: import_data4.Field.textarea({
3443
+ label: "Error",
3444
+ required: false,
3445
+ description: "Error message when status=failed"
3446
+ }),
3447
+ rejection_reason: import_data4.Field.textarea({
3448
+ label: "Rejection Reason",
3449
+ required: false,
3450
+ description: "Why the reviewer rejected (shown back to the LLM)"
3451
+ }),
3452
+ proposed_by: import_data4.Field.text({
3453
+ label: "Proposed By",
3454
+ required: false,
3455
+ maxLength: 128,
3456
+ description: "Principal id of the AI agent that proposed the action"
3457
+ }),
3458
+ decided_by: import_data4.Field.text({
3459
+ label: "Decided By",
3460
+ required: false,
3461
+ maxLength: 128,
3462
+ description: "User id of the human who approved/rejected"
3463
+ }),
3464
+ proposed_at: import_data4.Field.datetime({
3465
+ label: "Proposed At",
3466
+ required: true,
3467
+ defaultValue: "NOW()",
3468
+ readonly: true
3469
+ }),
3470
+ decided_at: import_data4.Field.datetime({
3471
+ label: "Decided At",
3472
+ required: false,
3473
+ description: "When approve/reject happened"
3474
+ })
3475
+ },
3476
+ indexes: [
3477
+ { fields: ["status"] },
3478
+ { fields: ["conversation_id"] },
3479
+ { fields: ["object_name"] },
3480
+ { fields: ["proposed_at"] }
3481
+ ],
3482
+ actions: [
3483
+ {
3484
+ name: "approve_pending_action",
3485
+ label: "Approve",
3486
+ type: "api",
3487
+ target: "/api/v1/ai/pending-actions/{recordId}/approve",
3488
+ method: "POST",
3489
+ locations: ["list_item", "record_header"],
3490
+ variant: "primary",
3491
+ confirmText: "Approve and execute this action now?",
3492
+ successMessage: "Action approved and executed.",
3493
+ // The approval click is the operator's authorisation gesture —
3494
+ // the LLM must not be allowed to bypass HITL by approving itself.
3495
+ aiExposed: false
3496
+ },
3497
+ {
3498
+ name: "reject_pending_action",
3499
+ label: "Reject",
3500
+ type: "api",
3501
+ target: "/api/v1/ai/pending-actions/{recordId}/reject",
3502
+ method: "POST",
3503
+ locations: ["list_item", "record_header"],
3504
+ variant: "danger",
3505
+ confirmText: "Reject this pending action? It will not be executed.",
3506
+ successMessage: "Action rejected.",
3507
+ aiExposed: false
3508
+ }
3509
+ ],
3510
+ enable: {
3511
+ trackHistory: false,
3512
+ searchable: false,
3513
+ apiEnabled: true,
3514
+ apiMethods: ["get", "list"],
3515
+ trash: false,
3516
+ mru: false
3517
+ }
3518
+ });
3519
+
3520
+ // src/views/ai-trace.view.ts
3521
+ var import_spec = require("@objectstack/spec");
3522
+ var AiTraceView = (0, import_spec.defineView)({
3523
+ list: {
3524
+ type: "grid",
3525
+ data: { provider: "object", object: "ai_traces" },
3526
+ columns: [
3527
+ { field: "created_at", label: "Time" },
3528
+ { field: "operation" },
3529
+ { field: "model" },
3530
+ { field: "agent_id", label: "Agent" },
3531
+ { field: "latency_ms", label: "Latency (ms)" },
3532
+ { field: "total_tokens", label: "Tokens" },
3533
+ { field: "cost_total", label: "Cost" },
3534
+ { field: "status" }
3535
+ ],
3536
+ sort: [{ field: "created_at", order: "desc" }],
3537
+ pagination: { pageSize: 50 },
3538
+ searchableFields: ["conversation_id", "agent_id", "model", "error"],
3539
+ filterableFields: ["operation", "model", "status"]
3540
+ },
3541
+ listViews: {
3542
+ errors: {
3543
+ label: "Errors",
3544
+ type: "grid",
3545
+ data: { provider: "object", object: "ai_traces" },
3546
+ columns: [
3547
+ { field: "created_at", label: "Time" },
3548
+ { field: "operation" },
3549
+ { field: "model" },
3550
+ { field: "latency_ms", label: "Latency (ms)" },
3551
+ { field: "error" }
3552
+ ],
3553
+ filter: [
3554
+ { field: "status", operator: "=", value: "error" }
3555
+ ],
3556
+ sort: [{ field: "created_at", order: "desc" }]
3557
+ },
3558
+ by_model: {
3559
+ label: "By Model",
3560
+ type: "grid",
3561
+ data: { provider: "object", object: "ai_traces" },
3562
+ columns: [
3563
+ { field: "model" },
3564
+ { field: "operation" },
3565
+ { field: "latency_ms", label: "Latency (ms)" },
3566
+ { field: "total_tokens", label: "Tokens" },
3567
+ { field: "cost_total", label: "Cost" },
3568
+ { field: "status" },
3569
+ { field: "created_at", label: "Time" }
3570
+ ],
3571
+ grouping: { fields: [{ field: "model" }] },
3572
+ sort: [{ field: "created_at", order: "desc" }]
3573
+ }
3574
+ }
3575
+ });
3576
+
3577
+ // src/views/ai-pending-action.view.ts
3578
+ var import_spec2 = require("@objectstack/spec");
3579
+ var AiPendingActionView = (0, import_spec2.defineView)({
3580
+ list: {
3581
+ type: "grid",
3582
+ data: { provider: "object", object: "ai_pending_actions" },
3583
+ columns: [
3584
+ { field: "proposed_at", label: "Proposed", type: "datetime-relative", width: 140 },
3585
+ { field: "status", width: 130 },
3586
+ { field: "object_name", label: "Object", width: 140 },
3587
+ { field: "action_name", label: "Action", width: 180 },
3588
+ { field: "proposed_by", label: "Proposed by", width: 160 },
3589
+ { field: "decided_by", label: "Decided by", width: 160 },
3590
+ { field: "decided_at", label: "Decided", type: "datetime-relative", width: 140 }
3591
+ ],
3592
+ sort: [{ field: "proposed_at", order: "desc" }],
3593
+ pagination: { pageSize: 50 },
3594
+ searchableFields: ["action_name", "object_name", "tool_name", "proposed_by"],
3595
+ filterableFields: ["status", "object_name", "action_name"],
3596
+ rowActions: ["approve_pending_action", "reject_pending_action"],
3597
+ // Click a row → open the detail drawer instead of navigating to a page.
3598
+ navigation: { mode: "drawer", view: "detail", width: "640px" },
3599
+ rowColor: {
3600
+ field: "status",
3601
+ mapping: {
3602
+ pending: "amber",
3603
+ approved: "blue",
3604
+ executed: "green",
3605
+ failed: "red",
3606
+ rejected: "gray"
3607
+ }
3608
+ }
3609
+ },
3610
+ form: {
3611
+ type: "drawer",
3612
+ data: { provider: "object", object: "ai_pending_actions" },
3613
+ sections: [
3614
+ {
3615
+ label: "Proposal",
3616
+ columns: 2,
3617
+ fields: [
3618
+ { field: "status", readonly: true },
3619
+ { field: "proposed_at", readonly: true, widget: "datetime-relative" },
3620
+ { field: "object_name", label: "Target object", readonly: true },
3621
+ { field: "action_name", label: "Action", readonly: true },
3622
+ { field: "tool_name", label: "Tool exposed to LLM", readonly: true, colSpan: 2 },
3623
+ { field: "proposed_by", label: "Proposed by (AI agent)", readonly: true, colSpan: 2 }
3624
+ ]
3625
+ },
3626
+ {
3627
+ label: "Tool input",
3628
+ collapsible: true,
3629
+ columns: 1,
3630
+ fields: [
3631
+ {
3632
+ field: "tool_input",
3633
+ label: "Arguments the LLM sent",
3634
+ readonly: true,
3635
+ widget: "json",
3636
+ colSpan: 1,
3637
+ helpText: "Pretty-printed JSON. Review carefully before approving \u2014 this is the exact payload that will be re-played against the handler."
3638
+ }
3639
+ ]
3640
+ },
3641
+ {
3642
+ label: "Conversation context",
3643
+ collapsible: true,
3644
+ collapsed: true,
3645
+ columns: 2,
3646
+ fields: [
3647
+ // Both are lookups — Studio renders them as links to the related
3648
+ // ai_conversations / ai_messages record so operators can jump to
3649
+ // the full transcript for context.
3650
+ { field: "conversation_id", label: "Conversation", readonly: true },
3651
+ { field: "message_id", label: "Assistant message", readonly: true }
3652
+ ]
3653
+ },
3654
+ {
3655
+ label: "Decision",
3656
+ collapsible: true,
3657
+ // Only meaningful once the row has been actioned; left collapsed
3658
+ // by default for pending rows so the eye lands on the proposal.
3659
+ collapsed: true,
3660
+ columns: 2,
3661
+ fields: [
3662
+ { field: "decided_by", label: "Decided by", readonly: true },
3663
+ { field: "decided_at", label: "Decided", readonly: true, widget: "datetime-relative" },
3664
+ {
3665
+ field: "rejection_reason",
3666
+ label: "Rejection reason",
3667
+ readonly: true,
3668
+ colSpan: 2,
3669
+ visibleOn: 'record.status == "rejected"'
3670
+ },
3671
+ {
3672
+ field: "result",
3673
+ label: "Execution result",
3674
+ readonly: true,
3675
+ widget: "json",
3676
+ colSpan: 2,
3677
+ visibleOn: 'record.status == "executed"'
3678
+ },
3679
+ {
3680
+ field: "error",
3681
+ label: "Error",
3682
+ readonly: true,
3683
+ colSpan: 2,
3684
+ visibleOn: 'record.status == "failed"'
3685
+ }
3686
+ ]
3687
+ }
3688
+ ]
3689
+ },
3690
+ formViews: {
3691
+ detail: {
3692
+ type: "drawer",
3693
+ data: { provider: "object", object: "ai_pending_actions" },
3694
+ // Mirror of the default form. Named separately so the list's
3695
+ // `navigation.view: 'detail'` resolves explicitly — Studio falls back
3696
+ // to `form` if a named view isn't registered, but being explicit
3697
+ // makes the wiring legible to readers of the metadata.
3698
+ sections: [
3699
+ {
3700
+ label: "Proposal",
3701
+ columns: 2,
3702
+ fields: [
3703
+ { field: "status", readonly: true },
3704
+ { field: "proposed_at", readonly: true, widget: "datetime-relative" },
3705
+ { field: "object_name", label: "Target object", readonly: true },
3706
+ { field: "action_name", label: "Action", readonly: true },
3707
+ { field: "tool_name", label: "Tool exposed to LLM", readonly: true, colSpan: 2 },
3708
+ { field: "proposed_by", label: "Proposed by (AI agent)", readonly: true, colSpan: 2 }
3709
+ ]
3710
+ },
3711
+ {
3712
+ label: "Tool input",
3713
+ collapsible: true,
3714
+ columns: 1,
3715
+ fields: [
3716
+ { field: "tool_input", label: "Arguments the LLM sent", readonly: true, widget: "json" }
3717
+ ]
3718
+ },
3719
+ {
3720
+ label: "Conversation context",
3721
+ collapsible: true,
3722
+ collapsed: true,
3723
+ columns: 2,
3724
+ fields: [
3725
+ { field: "conversation_id", label: "Conversation", readonly: true },
3726
+ { field: "message_id", label: "Assistant message", readonly: true }
3727
+ ]
3728
+ },
3729
+ {
3730
+ label: "Decision",
3731
+ collapsible: true,
3732
+ collapsed: true,
3733
+ columns: 2,
3734
+ fields: [
3735
+ { field: "decided_by", label: "Decided by", readonly: true },
3736
+ { field: "decided_at", label: "Decided", readonly: true, widget: "datetime-relative" },
3737
+ { field: "rejection_reason", label: "Rejection reason", readonly: true, colSpan: 2, visibleOn: 'record.status == "rejected"' },
3738
+ { field: "result", label: "Execution result", readonly: true, widget: "json", colSpan: 2, visibleOn: 'record.status == "executed"' },
3739
+ { field: "error", label: "Error", readonly: true, colSpan: 2, visibleOn: 'record.status == "failed"' }
3740
+ ]
3741
+ }
3742
+ ]
3743
+ }
3744
+ },
3745
+ listViews: {
3746
+ pending: {
3747
+ label: "Pending",
3748
+ type: "grid",
3749
+ data: { provider: "object", object: "ai_pending_actions" },
3750
+ columns: [
3751
+ { field: "proposed_at", label: "Proposed", type: "datetime-relative", width: 140 },
3752
+ { field: "object_name", label: "Object", width: 140 },
3753
+ { field: "action_name", label: "Action", width: 180 },
3754
+ { field: "proposed_by", label: "Proposed by", width: 160 },
3755
+ { field: "tool_name", label: "Tool", width: 200 }
3756
+ ],
3757
+ filter: [{ field: "status", operator: "=", value: "pending" }],
3758
+ sort: [{ field: "proposed_at", order: "desc" }],
3759
+ rowActions: ["approve_pending_action", "reject_pending_action"],
3760
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3761
+ },
3762
+ executed: {
3763
+ label: "Executed",
3764
+ type: "grid",
3765
+ data: { provider: "object", object: "ai_pending_actions" },
3766
+ columns: [
3767
+ { field: "decided_at", label: "Approved", type: "datetime-relative", width: 140 },
3768
+ { field: "object_name", label: "Object", width: 140 },
3769
+ { field: "action_name", label: "Action", width: 180 },
3770
+ { field: "decided_by", label: "Approved by", width: 160 },
3771
+ { field: "proposed_by", label: "Proposed by", width: 160 }
3772
+ ],
3773
+ filter: [{ field: "status", operator: "=", value: "executed" }],
3774
+ sort: [{ field: "decided_at", order: "desc" }],
3775
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3776
+ },
3777
+ rejected: {
3778
+ label: "Rejected",
3779
+ type: "grid",
3780
+ data: { provider: "object", object: "ai_pending_actions" },
3781
+ columns: [
3782
+ { field: "decided_at", label: "Rejected", type: "datetime-relative", width: 140 },
3783
+ { field: "object_name", label: "Object", width: 140 },
3784
+ { field: "action_name", label: "Action", width: 180 },
3785
+ { field: "decided_by", label: "Rejected by", width: 160 },
3786
+ { field: "rejection_reason", label: "Reason", wrap: true }
3787
+ ],
3788
+ filter: [{ field: "status", operator: "=", value: "rejected" }],
3789
+ sort: [{ field: "decided_at", order: "desc" }],
3790
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3791
+ },
3792
+ failed: {
3793
+ label: "Failed",
3794
+ type: "grid",
3795
+ data: { provider: "object", object: "ai_pending_actions" },
3796
+ columns: [
3797
+ { field: "decided_at", label: "When", type: "datetime-relative", width: 140 },
3798
+ { field: "object_name", label: "Object", width: 140 },
3799
+ { field: "action_name", label: "Action", width: 180 },
3800
+ { field: "error", wrap: true }
3801
+ ],
3802
+ filter: [{ field: "status", operator: "=", value: "failed" }],
3803
+ sort: [{ field: "decided_at", order: "desc" }],
3804
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3805
+ }
3806
+ }
3807
+ });
3808
+
2875
3809
  // src/plugin.ts
2876
3810
  init_data_tools();
2877
3811
  init_metadata_tools();
@@ -2924,8 +3858,11 @@ var SchemaRetriever = class {
2924
3858
  const lines = ["## Schema context (auto-injected)"];
2925
3859
  for (const hit of hits) {
2926
3860
  const obj = hit.object;
2927
- const label = obj.label ? ` \u2014 ${obj.label}` : "";
2928
- lines.push(`### ${obj.name}${label}`);
3861
+ const parts = [];
3862
+ if (obj.label) parts.push(obj.label);
3863
+ if (obj.pluralLabel && obj.pluralLabel !== obj.label) parts.push(`(${obj.pluralLabel})`);
3864
+ const header = parts.length > 0 ? ` \u2014 ${parts.join(" ")}` : "";
3865
+ lines.push(`### ${obj.name}${header}`);
2929
3866
  const fields = Object.entries(obj.fields ?? {}).slice(0, maxFieldsPerObject);
2930
3867
  for (const [name, field] of fields) {
2931
3868
  lines.push(` - ${name}: ${describeField(field)}`);
@@ -3025,17 +3962,17 @@ function describeField(field) {
3025
3962
  // src/tools/query-data.tool.ts
3026
3963
  var QueryPlanSchema = import_zod.z.object({
3027
3964
  objectName: import_zod.z.string().min(1).describe('The snake_case object name to query (e.g. "task", "account").'),
3028
- where: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional().describe(
3029
- 'Filter conditions as key-value pairs. Use MongoDB-style operators for ranges, e.g. {"amount": {"$gt": 100}}.'
3965
+ whereJson: import_zod.z.string().nullable().describe(
3966
+ 'Filter conditions encoded as a JSON object string. Examples: `{"status":"completed"}`, `{"subject":{"$contains":"Build"}}`, `{"amount":{"$gt":100}}`. Pass null to match all records.'
3030
3967
  ),
3031
- fields: import_zod.z.array(import_zod.z.string()).optional().describe("Field names to return. Omit to return all fields."),
3968
+ fields: import_zod.z.array(import_zod.z.string()).nullable().describe("Field names to return. Pass null to return all fields."),
3032
3969
  orderBy: import_zod.z.array(
3033
3970
  import_zod.z.object({
3034
3971
  field: import_zod.z.string(),
3035
3972
  order: import_zod.z.enum(["asc", "desc"])
3036
3973
  })
3037
- ).optional().describe("Sort order. First entry is primary sort key."),
3038
- limit: import_zod.z.number().int().min(1).max(200).optional().describe("Maximum number of records (default 20, max 200).")
3974
+ ).nullable().describe("Sort order. First entry is primary sort key. Pass null for no sort."),
3975
+ limit: import_zod.z.number().int().min(1).max(200).nullable().describe("Maximum number of records (default 20, max 200). Pass null for default.")
3039
3976
  });
3040
3977
  var QUERY_DATA_TOOL = {
3041
3978
  name: "query_data",
@@ -3046,10 +3983,6 @@ var QUERY_DATA_TOOL = {
3046
3983
  request: {
3047
3984
  type: "string",
3048
3985
  description: "The natural-language question to answer (paraphrase the user's request if needed for clarity)."
3049
- },
3050
- model: {
3051
- type: "string",
3052
- description: "Optional model id to use for query planning. Defaults to the AI service's default model."
3053
3986
  }
3054
3987
  },
3055
3988
  required: ["request"],
@@ -3060,7 +3993,7 @@ function createQueryDataHandler(ctx) {
3060
3993
  const retriever = new SchemaRetriever(ctx.metadata);
3061
3994
  const maxLimit = ctx.maxLimit ?? 100;
3062
3995
  return async (args) => {
3063
- const { request, model } = args;
3996
+ const { request } = args;
3064
3997
  if (!request || typeof request !== "string") {
3065
3998
  return JSON.stringify({ error: "query_data: `request` is required" });
3066
3999
  }
@@ -3079,14 +4012,13 @@ function createQueryDataHandler(ctx) {
3079
4012
  const planMessages = [
3080
4013
  {
3081
4014
  role: "system",
3082
- content: "You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\n" + snippet
4015
+ content: 'You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\nFilter operator hints:\n \u2022 For partial string matches (e.g. "task named Foo", "find X"), use case-insensitive substring matching with `$contains`: `{"subject": {"$contains": "Foo"}}`. Do NOT use equality unless the user clearly supplied the exact full value.\n \u2022 For numeric/date ranges use `$gt` / `$gte` / `$lt` / `$lte`.\n \u2022 For "is one of" use `$in: [...]`.\n \u2022 For exact equality just write the value: `{"status": "completed"}`.\n\n' + snippet
3083
4016
  },
3084
4017
  { role: "user", content: request }
3085
4018
  ];
3086
4019
  let plan;
3087
4020
  try {
3088
4021
  const generated = await ctx.ai.generateObject(planMessages, QueryPlanSchema, {
3089
- model,
3090
4022
  schemaName: "ObjectQLQueryPlan",
3091
4023
  schemaDescription: "A single ObjectQL find() query to answer the user request."
3092
4024
  });
@@ -3103,15 +4035,34 @@ function createQueryDataHandler(ctx) {
3103
4035
  });
3104
4036
  }
3105
4037
  const limit = Math.min(plan.limit ?? 20, maxLimit);
4038
+ let where;
4039
+ if (plan.whereJson) {
4040
+ try {
4041
+ const parsed = JSON.parse(plan.whereJson);
4042
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
4043
+ where = parsed;
4044
+ } else {
4045
+ return JSON.stringify({
4046
+ plan,
4047
+ error: `whereJson must encode a JSON object, got: ${plan.whereJson}`
4048
+ });
4049
+ }
4050
+ } catch (err) {
4051
+ return JSON.stringify({
4052
+ plan,
4053
+ error: `whereJson is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
4054
+ });
4055
+ }
4056
+ }
3106
4057
  try {
3107
4058
  const records = await ctx.dataEngine.find(plan.objectName, {
3108
- where: plan.where,
3109
- fields: plan.fields,
3110
- orderBy: plan.orderBy,
4059
+ where,
4060
+ fields: plan.fields ?? void 0,
4061
+ orderBy: plan.orderBy ?? void 0,
3111
4062
  limit
3112
4063
  });
3113
4064
  return JSON.stringify({
3114
- plan,
4065
+ plan: { ...plan, where },
3115
4066
  count: records.length,
3116
4067
  records
3117
4068
  });
@@ -3127,6 +4078,402 @@ function registerQueryDataTool(registry, context) {
3127
4078
  registry.register(QUERY_DATA_TOOL, createQueryDataHandler(context));
3128
4079
  }
3129
4080
 
4081
+ // src/tools/action-tools.ts
4082
+ function actionRequiresApproval(action) {
4083
+ return Boolean(
4084
+ action.confirmText || action.mode === "delete" || action.variant === "danger"
4085
+ );
4086
+ }
4087
+ function actionSkipReason(action, ctx) {
4088
+ if (action.aiExposed === false) {
4089
+ return "opted-out via aiExposed:false";
4090
+ }
4091
+ if (action.type === "url" || action.type === "modal" || action.type === "form") {
4092
+ return `type='${action.type}' is UI-only`;
4093
+ }
4094
+ if (action.type !== "script" && action.type !== "api" && action.type !== "flow") {
4095
+ return `type='${action.type}' not supported`;
4096
+ }
4097
+ if (action.type === "script" && !action.target && !action.body) {
4098
+ return "no target or body";
4099
+ }
4100
+ if ((action.type === "api" || action.type === "flow") && !action.target) {
4101
+ return `type='${action.type}' requires a target`;
4102
+ }
4103
+ if (ctx) {
4104
+ if (action.type === "flow" && !ctx.automation) {
4105
+ return "no automation service available";
4106
+ }
4107
+ if (action.type === "api" && !ctx.apiClient && !ctx.apiBaseUrl) {
4108
+ return "no apiClient or apiBaseUrl configured";
4109
+ }
4110
+ }
4111
+ if (actionRequiresApproval(action)) {
4112
+ const approvalReady = ctx?.enableActionApproval === true && Boolean(ctx?.aiService?.proposePendingAction);
4113
+ if (!approvalReady) {
4114
+ if (action.confirmText) return "requires confirmation (confirmText set)";
4115
+ if (action.mode === "delete") return "mode='delete' \u2014 destructive";
4116
+ if (action.variant === "danger") return "variant='danger' \u2014 destructive";
4117
+ }
4118
+ }
4119
+ return null;
4120
+ }
4121
+ function fieldTypeToJsonType(t) {
4122
+ switch (t) {
4123
+ case "number":
4124
+ case "currency":
4125
+ case "percent":
4126
+ case "rating":
4127
+ case "slider":
4128
+ case "autonumber":
4129
+ return "number";
4130
+ case "boolean":
4131
+ case "toggle":
4132
+ return "boolean";
4133
+ case "multiselect":
4134
+ case "checkboxes":
4135
+ case "tags":
4136
+ return "array";
4137
+ default:
4138
+ return "string";
4139
+ }
4140
+ }
4141
+ function resolveParam(param, ownerObject, allObjects) {
4142
+ const fieldRef = param.field;
4143
+ const owner = param.objectOverride && allObjects.get(param.objectOverride) ? allObjects.get(param.objectOverride) : ownerObject;
4144
+ const field = fieldRef ? owner?.fields?.[fieldRef] : void 0;
4145
+ const name = param.name ?? fieldRef;
4146
+ if (!name) return null;
4147
+ const type = param.type ?? field?.type;
4148
+ const jsonType = fieldTypeToJsonType(type);
4149
+ const schema = { type: jsonType };
4150
+ const label = typeof param.label === "string" ? param.label : field?.label;
4151
+ const help = param.helpText ?? field?.description;
4152
+ const description = [label, help].filter(Boolean).join(" \u2014 ") || void 0;
4153
+ if (description) schema.description = description;
4154
+ const optionSource = param.options ?? field?.options;
4155
+ if (Array.isArray(optionSource) && optionSource.length > 0) {
4156
+ const values = optionSource.map((o) => typeof o === "string" ? o : o.value).filter((v) => typeof v === "string");
4157
+ if (values.length > 0) {
4158
+ schema.enum = jsonType === "array" ? void 0 : values;
4159
+ if (jsonType === "array") {
4160
+ schema.items = { type: "string", enum: values };
4161
+ }
4162
+ }
4163
+ } else if (jsonType === "array") {
4164
+ schema.items = { type: "string" };
4165
+ }
4166
+ if (param.defaultValue !== void 0) {
4167
+ schema.default = param.defaultValue;
4168
+ }
4169
+ const required = Boolean(param.required ?? field?.required ?? false);
4170
+ return { name, schema, required };
4171
+ }
4172
+ function buildParametersSchema(action, ownerObject, allObjects) {
4173
+ const properties = {};
4174
+ const required = [];
4175
+ const isRowContext = Array.isArray(action.locations) && action.locations.some((l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related");
4176
+ if (action.objectName && isRowContext) {
4177
+ properties.recordId = {
4178
+ type: "string",
4179
+ description: `The ${action.objectName} record id to act on.`
4180
+ };
4181
+ if (action.recordIdParam || action.recordIdField) {
4182
+ required.push("recordId");
4183
+ }
4184
+ }
4185
+ for (const param of action.params ?? []) {
4186
+ const resolved = resolveParam(param, ownerObject, allObjects);
4187
+ if (!resolved) continue;
4188
+ properties[resolved.name] = resolved.schema;
4189
+ if (resolved.required) required.push(resolved.name);
4190
+ }
4191
+ return {
4192
+ type: "object",
4193
+ properties,
4194
+ ...required.length > 0 ? { required } : {},
4195
+ additionalProperties: false
4196
+ };
4197
+ }
4198
+ function actionToolName(action, prefix = "action_") {
4199
+ return `${prefix}${action.name}`;
4200
+ }
4201
+ function describeAction(action, ownerObject) {
4202
+ const label = typeof action.label === "string" ? action.label : action.name.replace(/_/g, " ");
4203
+ const target = action.objectName ?? ownerObject?.name;
4204
+ const targetLabel = ownerObject?.label ?? target;
4205
+ const parts = [];
4206
+ parts.push(`${label}${targetLabel ? ` \u2014 operates on ${targetLabel}` : ""}.`);
4207
+ if (action.successMessage && typeof action.successMessage === "string") {
4208
+ parts.push(`On success: ${action.successMessage}`);
4209
+ }
4210
+ if (action.mode) parts.push(`Mode: ${action.mode}.`);
4211
+ parts.push(
4212
+ "Use this when the user asks to perform this operation in natural language."
4213
+ );
4214
+ return parts.join(" ");
4215
+ }
4216
+ function actionToToolDefinition(action, ownerObject, allObjects, toolPrefix = "action_") {
4217
+ if (action.aiExposed === false) return null;
4218
+ if (action.type === "url" || action.type === "modal" || action.type === "form") return null;
4219
+ return {
4220
+ name: actionToolName(action, toolPrefix),
4221
+ description: describeAction(action, ownerObject),
4222
+ parameters: buildParametersSchema(action, ownerObject, allObjects)
4223
+ };
4224
+ }
4225
+ function buildHandlerEngineAdapter(engine) {
4226
+ return {
4227
+ update: (object, id, data) => engine.update(object, { ...data, id }, { where: { id } }),
4228
+ insert: (object, data) => engine.insert(object, data),
4229
+ find: (object, where) => engine.find(object, { where }),
4230
+ delete: async (object, ids) => {
4231
+ if (!Array.isArray(ids) || ids.length === 0) return 0;
4232
+ let count = 0;
4233
+ for (const id of ids) {
4234
+ await engine.delete(object, { where: { id } });
4235
+ count++;
4236
+ }
4237
+ return count;
4238
+ }
4239
+ };
4240
+ }
4241
+ function createActionToolHandler(action, ctx) {
4242
+ const principal = ctx.principal ?? { id: "ai_agent", name: "AI Assistant" };
4243
+ const requiresRecord = Array.isArray(action.locations) && action.locations.some(
4244
+ (l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related"
4245
+ );
4246
+ return async (args) => {
4247
+ const objectName = action.objectName;
4248
+ const target = action.target;
4249
+ const result = {
4250
+ ok: false,
4251
+ action: action.name,
4252
+ objectName
4253
+ };
4254
+ if (!objectName) {
4255
+ result.error = "Action has no objectName; cannot dispatch.";
4256
+ return JSON.stringify(result);
4257
+ }
4258
+ if (!target && action.type !== "script") {
4259
+ result.error = "Action has no target.";
4260
+ return JSON.stringify(result);
4261
+ }
4262
+ const recordId = typeof args.recordId === "string" && args.recordId.length > 0 ? args.recordId : void 0;
4263
+ let record;
4264
+ if (requiresRecord) {
4265
+ if (!recordId) {
4266
+ result.error = `recordId is required for this action \u2014 supply the id of the ${objectName} record to act on (use query_data first if you don't have it).`;
4267
+ return JSON.stringify(result);
4268
+ }
4269
+ try {
4270
+ const found = await ctx.dataEngine.find(objectName, {
4271
+ where: { id: recordId },
4272
+ limit: 1
4273
+ });
4274
+ record = found[0];
4275
+ if (!record) {
4276
+ result.error = `Record ${recordId} not found in ${objectName}.`;
4277
+ return JSON.stringify(result);
4278
+ }
4279
+ result.recordId = recordId;
4280
+ } catch (err) {
4281
+ result.error = `Failed to load record: ${err instanceof Error ? err.message : String(err)}`;
4282
+ return JSON.stringify(result);
4283
+ }
4284
+ }
4285
+ const { recordId: _omit, ...userParams } = args;
4286
+ if (ctx.enableActionApproval && actionRequiresApproval(action) && ctx.aiService?.proposePendingAction) {
4287
+ try {
4288
+ const toolName = `${ctx.toolPrefix ?? "action_"}${action.name}`;
4289
+ const { id } = await ctx.aiService.proposePendingAction({
4290
+ objectName,
4291
+ actionName: action.name,
4292
+ toolName,
4293
+ toolInput: args,
4294
+ proposedBy: principal.id
4295
+ });
4296
+ const pending = {
4297
+ ok: true,
4298
+ action: action.name,
4299
+ objectName,
4300
+ recordId,
4301
+ status: "pending_approval",
4302
+ pendingActionId: id,
4303
+ message: `Action '${action.name}' is destructive and requires human approval. Proposal queued as ${id}. An operator must approve via Studio's pending-actions inbox before it runs. Do NOT call this tool again for the same intent \u2014 wait for the operator.`
4304
+ };
4305
+ return JSON.stringify(pending);
4306
+ } catch (err) {
4307
+ result.error = `Failed to enqueue approval: ${err instanceof Error ? err.message : String(err)}`;
4308
+ return JSON.stringify(result);
4309
+ }
4310
+ }
4311
+ try {
4312
+ let out;
4313
+ if (action.type === "api") {
4314
+ out = await dispatchApiAction(action, ctx, userParams, record, recordId);
4315
+ } else if (action.type === "flow") {
4316
+ out = await dispatchFlowAction(action, ctx, userParams, record, principal);
4317
+ } else {
4318
+ out = await dispatchScriptAction(action, ctx, userParams, record, principal);
4319
+ }
4320
+ result.ok = true;
4321
+ result.result = out ?? null;
4322
+ const successMsg = typeof action.successMessage === "string" ? action.successMessage : void 0;
4323
+ result.message = successMsg ?? `Action '${action.name}' executed successfully.`;
4324
+ return JSON.stringify(result);
4325
+ } catch (err) {
4326
+ result.error = err instanceof Error ? err.message : String(err);
4327
+ return JSON.stringify(result);
4328
+ }
4329
+ };
4330
+ }
4331
+ async function dispatchScriptAction(action, ctx, params, record, principal) {
4332
+ const engineAdapter = buildHandlerEngineAdapter(ctx.dataEngine);
4333
+ const handlerCtx = { record, user: principal, engine: engineAdapter, params };
4334
+ return await ctx.dataEngine.executeAction?.(action.objectName, action.target, handlerCtx);
4335
+ }
4336
+ function buildApiRequestBody(action, args, record, recordId) {
4337
+ const shape = action.bodyShape;
4338
+ const wrapKey = shape && typeof shape === "object" && "wrap" in shape && typeof shape.wrap === "string" ? shape.wrap : void 0;
4339
+ const body = wrapKey ? { [wrapKey]: { ...args } } : { ...args };
4340
+ if (action.recordIdParam) {
4341
+ const idField = action.recordIdField ?? "id";
4342
+ const idValue = record ? record[idField] : recordId;
4343
+ if (idValue !== void 0) body[action.recordIdParam] = idValue;
4344
+ }
4345
+ if (action.bodyExtra && typeof action.bodyExtra === "object") {
4346
+ Object.assign(body, action.bodyExtra);
4347
+ }
4348
+ return body;
4349
+ }
4350
+ async function dispatchApiAction(action, ctx, params, record, recordId) {
4351
+ const client = ctx.apiClient ?? (ctx.apiBaseUrl ? createFetchApiClient({ baseUrl: ctx.apiBaseUrl, headers: ctx.apiHeaders }) : void 0);
4352
+ if (!client) {
4353
+ throw new Error('No apiClient configured for type:"api" action dispatch.');
4354
+ }
4355
+ const method = action.method ?? "POST";
4356
+ const body = buildApiRequestBody(action, params, record, recordId);
4357
+ return await client.request({
4358
+ url: action.target,
4359
+ method,
4360
+ body: method === "GET" || method === "DELETE" ? void 0 : body,
4361
+ headers: ctx.apiHeaders
4362
+ });
4363
+ }
4364
+ async function dispatchFlowAction(action, ctx, params, record, principal) {
4365
+ if (!ctx.automation) {
4366
+ throw new Error('No automation service available for type:"flow" action dispatch.');
4367
+ }
4368
+ const result = await ctx.automation.execute(action.target, {
4369
+ triggerData: { record, params, user: principal, action: action.name }
4370
+ });
4371
+ if (result && typeof result === "object" && "success" in result && result.success === false) {
4372
+ throw new Error(
4373
+ `Flow '${action.target}' failed: ${result.error ?? "unknown error"}`
4374
+ );
4375
+ }
4376
+ return result;
4377
+ }
4378
+ function createFetchApiClient(options) {
4379
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4380
+ if (!fetchImpl) {
4381
+ throw new Error("createFetchApiClient: no global fetch available; pass options.fetch.");
4382
+ }
4383
+ return {
4384
+ async request({ url, method, body, headers }) {
4385
+ const absolute = /^https?:\/\//.test(url) ? url : `${(options.baseUrl ?? "").replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
4386
+ const res = await fetchImpl(absolute, {
4387
+ method,
4388
+ headers: {
4389
+ "Content-Type": "application/json",
4390
+ ...options.headers ?? {},
4391
+ ...headers ?? {}
4392
+ },
4393
+ body: body ? JSON.stringify(body) : void 0
4394
+ });
4395
+ const text = await res.text();
4396
+ const parsed = text ? safeJsonParse(text) : null;
4397
+ if (!res.ok) {
4398
+ const msg = parsed && typeof parsed === "object" && "error" in parsed ? parsed.error : text;
4399
+ throw new Error(`${method} ${absolute} \u2192 ${res.status}: ${typeof msg === "string" ? msg : JSON.stringify(msg)}`);
4400
+ }
4401
+ return parsed;
4402
+ }
4403
+ };
4404
+ }
4405
+ function safeJsonParse(s) {
4406
+ try {
4407
+ return JSON.parse(s);
4408
+ } catch {
4409
+ return s;
4410
+ }
4411
+ }
4412
+ async function registerActionsAsTools(registry, context) {
4413
+ const objects = await context.metadata.listObjects();
4414
+ const objMap = new Map(
4415
+ objects.filter((o) => Boolean(o?.name)).map((o) => [o.name, o])
4416
+ );
4417
+ const registered = [];
4418
+ const skipped = [];
4419
+ const prefix = context.toolPrefix ?? "action_";
4420
+ for (const obj of objects) {
4421
+ if (!obj?.actions || !Array.isArray(obj.actions)) continue;
4422
+ for (const action of obj.actions) {
4423
+ if (!action || typeof action.name !== "string") continue;
4424
+ const normalized = {
4425
+ ...action,
4426
+ objectName: action.objectName ?? obj.name
4427
+ };
4428
+ const reason = actionSkipReason(normalized, {
4429
+ automation: context.automation,
4430
+ apiClient: context.apiClient,
4431
+ apiBaseUrl: context.apiBaseUrl,
4432
+ enableActionApproval: context.enableActionApproval,
4433
+ aiService: context.aiService
4434
+ });
4435
+ if (reason !== null) {
4436
+ skipped.push({ action: normalized.name, reason });
4437
+ continue;
4438
+ }
4439
+ const definition = actionToToolDefinition(normalized, obj, objMap, prefix);
4440
+ if (!definition) continue;
4441
+ if (registry.has(definition.name)) {
4442
+ skipped.push({ action: normalized.name, reason: "tool name already registered" });
4443
+ continue;
4444
+ }
4445
+ const handler = createActionToolHandler(normalized, context);
4446
+ registry.register(definition, handler);
4447
+ registered.push(definition.name);
4448
+ if (context.enableActionApproval && actionRequiresApproval(normalized) && context.aiService?.registerPendingActionDispatcher) {
4449
+ const bypassCtx = {
4450
+ ...context,
4451
+ enableActionApproval: false
4452
+ };
4453
+ const directHandler = createActionToolHandler(normalized, bypassCtx);
4454
+ context.aiService.registerPendingActionDispatcher(
4455
+ definition.name,
4456
+ async (input) => {
4457
+ const raw = await directHandler(input);
4458
+ let parsed = raw;
4459
+ try {
4460
+ parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
4461
+ } catch {
4462
+ parsed = raw;
4463
+ }
4464
+ if (parsed && typeof parsed === "object" && "ok" in parsed && parsed.ok === false) {
4465
+ const errMsg = parsed.error != null ? String(parsed.error) : "action handler reported failure";
4466
+ throw new Error(errMsg);
4467
+ }
4468
+ return parsed;
4469
+ }
4470
+ );
4471
+ }
4472
+ }
4473
+ }
4474
+ return { registered, skipped };
4475
+ }
4476
+
3130
4477
  // src/agent-runtime.ts
3131
4478
  var import_ai7 = require("@objectstack/spec/ai");
3132
4479
  var AgentRuntime = class {
@@ -3429,6 +4776,16 @@ var SkillRegistry = class {
3429
4776
  const resolved = [];
3430
4777
  for (const skill of skills) {
3431
4778
  for (const toolName of skill.tools) {
4779
+ if (toolName.endsWith("*")) {
4780
+ const prefix = toolName.slice(0, -1);
4781
+ for (const def2 of availableTools) {
4782
+ if (!def2.name.startsWith(prefix)) continue;
4783
+ if (seen.has(def2.name)) continue;
4784
+ resolved.push(def2);
4785
+ seen.add(def2.name);
4786
+ }
4787
+ continue;
4788
+ }
3432
4789
  if (seen.has(toolName)) continue;
3433
4790
  const def = toolMap.get(toolName);
3434
4791
  if (def) {
@@ -3492,7 +4849,8 @@ Always answer in the same language the user is using. Detailed tool-usage guidan
3492
4849
  maxTokens: 4096
3493
4850
  },
3494
4851
  // Capability bundle lives on the skill; the agent only references it.
3495
- skills: ["data_explorer"],
4852
+ // `data_explorer` = read side, `actions_executor` = write side.
4853
+ skills: ["data_explorer", "actions_executor"],
3496
4854
  active: true,
3497
4855
  visibility: "global",
3498
4856
  guardrails: {
@@ -3572,6 +4930,7 @@ Guidelines:
3572
4930
  7. Never expose internal IDs unless the user explicitly asks for them.
3573
4931
  8. Always answer in the same language the user is using.`,
3574
4932
  tools: [
4933
+ "query_data",
3575
4934
  "list_objects",
3576
4935
  "describe_object",
3577
4936
  "query_records",
@@ -3641,6 +5000,44 @@ Guidelines:
3641
5000
  active: true
3642
5001
  };
3643
5002
 
5003
+ // src/skills/actions-executor-skill.ts
5004
+ var ACTIONS_EXECUTOR_SKILL = {
5005
+ name: "actions_executor",
5006
+ label: "Action Executor",
5007
+ description: "Perform business operations on the user's data \u2014 invoke actions like 'mark as complete', 'start task', 'clone record' through natural language.",
5008
+ instructions: `You can perform business operations by invoking the user's registered actions.
5009
+
5010
+ Capabilities:
5011
+ - Each tool whose name starts with \`action_\` is a business operation declared on an object.
5012
+ - Read the tool description carefully \u2014 it tells you what the action does and what record types it applies to.
5013
+ - Most actions need a \`recordId\` argument. If you don't already have one from a prior \`query_data\` call, run \`query_data\` first to find the right record, then invoke the action with its id.
5014
+
5015
+ Guidelines:
5016
+ 1. Confirm intent \u2014 when the user says "complete it" / "start that one", make sure you know *which* record they mean. Ask if ambiguous.
5017
+ 2. Use \`query_data\` to look up records by natural-language description ("the design review task", "tickets assigned to me").
5018
+ 3. After invoking an action, the tool returns \`{ ok, message, result }\`. Summarise success in plain language; surface errors verbatim.
5019
+ 4. Never invent recordIds. If \`query_data\` didn't return one, tell the user instead of guessing.
5020
+ 5. Action tools are pre-filtered for safety \u2014 destructive operations (\`mode: 'delete'\`, \`variant: 'danger'\`, anything with \`confirmText\`) are *not* exposed here and require explicit user confirmation in the UI.
5021
+ 6. Always answer in the same language the user is using.`,
5022
+ // Dynamically materialised: the runtime registers one tool per Action,
5023
+ // and the skill subscribes to the whole family via the `action_*`
5024
+ // glob (resolved by SkillRegistry.flattenToTools).
5025
+ tools: ["action_*"],
5026
+ triggerPhrases: [
5027
+ "complete",
5028
+ "mark as",
5029
+ "start",
5030
+ "finish",
5031
+ "clone",
5032
+ "duplicate",
5033
+ "do it",
5034
+ "run",
5035
+ "invoke",
5036
+ "execute"
5037
+ ],
5038
+ active: true
5039
+ };
5040
+
3644
5041
  // src/adapters/vercel-adapter.ts
3645
5042
  var import_ai9 = require("ai");
3646
5043
  function buildVercelOptions(options) {
@@ -3949,26 +5346,29 @@ var AIServicePlugin = class {
3949
5346
  ctx.logger.info(`[AI] ModelRegistry initialised with ${modelRegistry.size} model(s)`);
3950
5347
  }
3951
5348
  let traceRecorder;
5349
+ let dataEngine;
5350
+ try {
5351
+ const engine = ctx.getService("data");
5352
+ if (engine && typeof engine.insert === "function") {
5353
+ dataEngine = engine;
5354
+ }
5355
+ } catch {
5356
+ }
3952
5357
  if (this.options.traceRecorder === null) {
3953
5358
  ctx.logger.debug("[AI] Tracing disabled (traceRecorder=null)");
3954
5359
  } else if (this.options.traceRecorder) {
3955
5360
  traceRecorder = this.options.traceRecorder;
3956
- } else {
3957
- try {
3958
- const engine = ctx.getService("data");
3959
- if (engine && typeof engine.insert === "function") {
3960
- traceRecorder = new ObjectQLTraceRecorder(engine, { logger: ctx.logger });
3961
- ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
3962
- }
3963
- } catch {
3964
- }
5361
+ } else if (dataEngine) {
5362
+ traceRecorder = new ObjectQLTraceRecorder(dataEngine, { logger: ctx.logger });
5363
+ ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
3965
5364
  }
3966
5365
  const config = {
3967
5366
  adapter,
3968
5367
  logger: ctx.logger,
3969
5368
  conversationService,
3970
5369
  modelRegistry,
3971
- traceRecorder
5370
+ traceRecorder,
5371
+ dataEngine
3972
5372
  };
3973
5373
  this.service = new AIService(config);
3974
5374
  if (hasExisting) {
@@ -3983,7 +5383,8 @@ var AIServicePlugin = class {
3983
5383
  type: "plugin",
3984
5384
  scope: "project",
3985
5385
  namespace: "ai",
3986
- objects: [AiConversationObject, AiMessageObject, AiTraceObject]
5386
+ objects: [AiConversationObject, AiMessageObject, AiTraceObject, AiPendingActionObject],
5387
+ views: [AiTraceView, AiPendingActionView]
3987
5388
  });
3988
5389
  if (this.options.debug) {
3989
5390
  ctx.hook("ai:beforeChat", async (messages) => {
@@ -4022,6 +5423,44 @@ var AIServicePlugin = class {
4022
5423
  dataEngine
4023
5424
  });
4024
5425
  ctx.logger.info("[AI] query_data tool registered");
5426
+ try {
5427
+ let automation;
5428
+ try {
5429
+ automation = ctx.getService("automation");
5430
+ } catch {
5431
+ automation = void 0;
5432
+ }
5433
+ const apiBaseUrl = this.options.apiActionBaseUrl ?? process.env.OS_AI_ACTION_API_BASE_URL;
5434
+ const apiHeaders = this.options.apiActionHeaders;
5435
+ const { registered, skipped } = await registerActionsAsTools(
5436
+ this.service.toolRegistry,
5437
+ {
5438
+ metadata: metadataService,
5439
+ dataEngine,
5440
+ automation,
5441
+ apiBaseUrl,
5442
+ apiHeaders,
5443
+ enableActionApproval: this.options.enableActionApproval ?? false,
5444
+ aiService: this.service
5445
+ }
5446
+ );
5447
+ if (registered.length > 0) {
5448
+ ctx.logger.info(
5449
+ `[AI] ${registered.length} action tool(s) registered: ${registered.join(", ")}`
5450
+ );
5451
+ }
5452
+ if (skipped.length > 0) {
5453
+ ctx.logger.debug(
5454
+ `[AI] Skipped ${skipped.length} action(s) for AI exposure`,
5455
+ { skipped }
5456
+ );
5457
+ }
5458
+ } catch (err) {
5459
+ ctx.logger.warn(
5460
+ "[AI] Failed to register action tools",
5461
+ err instanceof Error ? { error: err.message } : { error: String(err) }
5462
+ );
5463
+ }
4025
5464
  }
4026
5465
  if (metadataService) {
4027
5466
  const { DATA_TOOL_DEFINITIONS: DATA_TOOL_DEFINITIONS2 } = await Promise.resolve().then(() => (init_data_tools(), data_tools_exports));
@@ -4073,6 +5512,19 @@ var AIServicePlugin = class {
4073
5512
  } catch (err) {
4074
5513
  ctx.logger.warn("[AI] Failed to register data_explorer skill", err instanceof Error ? { error: err.message } : { error: String(err) });
4075
5514
  }
5515
+ try {
5516
+ const skillExists = typeof metadataService.exists === "function" ? await withTimeout(metadataService.exists("skill", ACTIONS_EXECUTOR_SKILL.name)) : false;
5517
+ if (skillExists === null) {
5518
+ ctx.logger.warn("[AI] Metadata service timed out checking actions_executor skill, skipping");
5519
+ } else if (!skillExists) {
5520
+ await withTimeout(metadataService.register("skill", ACTIONS_EXECUTOR_SKILL.name, ACTIONS_EXECUTOR_SKILL));
5521
+ ctx.logger.info("[AI] actions_executor skill registered");
5522
+ } else {
5523
+ ctx.logger.debug("[AI] actions_executor skill already exists, skipping auto-registration");
5524
+ }
5525
+ } catch (err) {
5526
+ ctx.logger.warn("[AI] Failed to register actions_executor skill", err instanceof Error ? { error: err.message } : { error: String(err) });
5527
+ }
4076
5528
  }
4077
5529
  }
4078
5530
  } catch {
@@ -4170,6 +5622,9 @@ var AIServicePlugin = class {
4170
5622
  const toolRoutes = buildToolRoutes(this.service, ctx.logger);
4171
5623
  routes.push(...toolRoutes);
4172
5624
  ctx.logger.info(`[AI] Tool routes registered (${toolRoutes.length} routes)`);
5625
+ const pendingRoutes = buildPendingActionRoutes(this.service, ctx.logger);
5626
+ routes.push(...pendingRoutes);
5627
+ ctx.logger.info(`[AI] Pending-action routes registered (${pendingRoutes.length} routes)`);
4173
5628
  if (metadataService) {
4174
5629
  const skillRegistry = new SkillRegistry(metadataService);
4175
5630
  const agentRuntime = new AgentRuntime(metadataService, skillRegistry);
@@ -4533,16 +5988,20 @@ function registerPackageTools(registry, context) {
4533
5988
  }
4534
5989
  // Annotate the CommonJS export names for ESM import in node:
4535
5990
  0 && (module.exports = {
5991
+ ACTIONS_EXECUTOR_SKILL,
4536
5992
  AIService,
4537
5993
  AIServicePlugin,
4538
5994
  AgentRuntime,
4539
5995
  AiConversationObject,
4540
5996
  AiMessageObject,
4541
5997
  AiTraceObject,
5998
+ AiTraceView,
4542
5999
  DATA_CHAT_AGENT,
6000
+ DATA_EXPLORER_SKILL,
4543
6001
  DATA_TOOL_DEFINITIONS,
4544
6002
  InMemoryConversationService,
4545
6003
  METADATA_ASSISTANT_AGENT,
6004
+ METADATA_AUTHORING_SKILL,
4546
6005
  METADATA_TOOL_DEFINITIONS,
4547
6006
  MemoryLLMAdapter,
4548
6007
  ModelRegistry,
@@ -4555,6 +6014,9 @@ function registerPackageTools(registry, context) {
4555
6014
  SkillRegistry,
4556
6015
  ToolRegistry,
4557
6016
  VercelLLMAdapter,
6017
+ actionSkipReason,
6018
+ actionToToolDefinition,
6019
+ actionToolName,
4558
6020
  addFieldTool,
4559
6021
  buildAIRoutes,
4560
6022
  buildAgentRoutes,
@@ -4574,6 +6036,7 @@ function registerPackageTools(registry, context) {
4574
6036
  listObjectsTool,
4575
6037
  listPackagesTool,
4576
6038
  modifyFieldTool,
6039
+ registerActionsAsTools,
4577
6040
  registerDataTools,
4578
6041
  registerMetadataTools,
4579
6042
  registerPackageTools,