@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.js CHANGED
@@ -844,8 +844,112 @@ var MemoryLLMAdapter = class {
844
844
  async chat(messages, options) {
845
845
  const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
846
846
  const userContent = lastUserMessage?.content;
847
- const text = typeof userContent === "string" ? userContent : "(complex content)";
848
- const content = lastUserMessage ? `[memory] ${text}` : "[memory] (no user message)";
847
+ const userText = typeof userContent === "string" ? userContent : "(complex content)";
848
+ const tools = options?.tools;
849
+ const hasQueryDataTool = Array.isArray(tools) && tools.some((t) => t?.name === "query_data");
850
+ const alreadyCalledQueryData = messages.some(
851
+ (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some((c) => c?.toolName === "query_data")
852
+ );
853
+ const alreadyCalledAction = messages.some(
854
+ (m) => m.role === "tool" && Array.isArray(m.content) && m.content.some(
855
+ (c) => typeof c?.toolName === "string" && c.toolName.startsWith("action_")
856
+ )
857
+ );
858
+ if (Array.isArray(tools) && !alreadyCalledAction && lastUserMessage) {
859
+ const actionTools = tools.filter((t) => typeof t?.name === "string" && t.name.startsWith("action_"));
860
+ const chosen = pickActionTool(userText, actionTools);
861
+ if (chosen) {
862
+ const recordId = extractRecordIdFromMessages(messages, userText);
863
+ if (recordId) {
864
+ const toolCallId = `memory_tc_${Date.now().toString(36)}`;
865
+ return {
866
+ content: "",
867
+ model: options?.model ?? "memory",
868
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
869
+ toolCalls: [
870
+ {
871
+ type: "tool-call",
872
+ toolCallId,
873
+ toolName: chosen.name,
874
+ input: { recordId }
875
+ }
876
+ ]
877
+ };
878
+ }
879
+ }
880
+ }
881
+ if (hasQueryDataTool && !alreadyCalledQueryData && lastUserMessage) {
882
+ const toolCallId = `memory_tc_${Date.now().toString(36)}`;
883
+ return {
884
+ content: "",
885
+ model: options?.model ?? "memory",
886
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
887
+ toolCalls: [
888
+ {
889
+ type: "tool-call",
890
+ toolCallId,
891
+ toolName: "query_data",
892
+ input: { request: userText }
893
+ }
894
+ ]
895
+ };
896
+ }
897
+ if (alreadyCalledAction) {
898
+ const lastTool = [...messages].reverse().find((m) => m.role === "tool");
899
+ const part = Array.isArray(lastTool?.content) ? lastTool.content.find((c) => typeof c?.toolName === "string" && c.toolName.startsWith("action_")) : void 0;
900
+ const raw = part?.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part?.result;
901
+ let payload = {};
902
+ if (typeof raw === "string") {
903
+ try {
904
+ payload = JSON.parse(raw);
905
+ } catch {
906
+ }
907
+ } else if (raw && typeof raw === "object") {
908
+ payload = raw;
909
+ }
910
+ if (payload.error) {
911
+ return {
912
+ content: `[memory] action ${payload.action ?? ""} failed: ${payload.error}`,
913
+ model: options?.model ?? "memory",
914
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
915
+ };
916
+ }
917
+ return {
918
+ content: `[memory] ${payload.message ?? "Action executed."} (${payload.action ?? "action"})`,
919
+ model: options?.model ?? "memory",
920
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
921
+ };
922
+ }
923
+ if (alreadyCalledQueryData) {
924
+ const lastTool = [...messages].reverse().find((m) => m.role === "tool");
925
+ const part = Array.isArray(lastTool?.content) ? lastTool.content.find((c) => c?.toolName === "query_data") : void 0;
926
+ let payload = {};
927
+ const raw = part?.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part?.result;
928
+ if (typeof raw === "string") {
929
+ try {
930
+ payload = JSON.parse(raw);
931
+ } catch {
932
+ payload = {};
933
+ }
934
+ } else if (raw && typeof raw === "object") {
935
+ payload = raw;
936
+ }
937
+ if (payload.error) {
938
+ return {
939
+ content: `[memory] query_data failed: ${payload.error}`,
940
+ model: options?.model ?? "memory",
941
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
942
+ };
943
+ }
944
+ const records = payload.records ?? [];
945
+ const count = payload.count ?? records.length;
946
+ return {
947
+ content: `[memory] Found ${count} record${count === 1 ? "" : "s"} for "${userText}".`,
948
+ model: options?.model ?? "memory",
949
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
950
+ };
951
+ }
952
+ const content = lastUserMessage ? `[memory] ${userText}` : "[memory] (no user message)";
849
953
  return {
850
954
  content,
851
955
  model: options?.model ?? "memory",
@@ -897,23 +1001,42 @@ var MemoryLLMAdapter = class {
897
1001
  */
898
1002
  async generateObject(messages, schema, options) {
899
1003
  const sys = messages.filter((m) => m.role === "system").map((m) => typeof m.content === "string" ? m.content : "").join("\n");
900
- const headerRe = /^###\s+([a-z0-9_]+)\b/gim;
1004
+ const headerRe = /^###\s+([a-z0-9_]+)(?:\s+—\s+([^\n]+))?/gim;
901
1005
  const candidates = [];
902
1006
  for (const match of sys.matchAll(headerRe)) {
903
- if (match[1]) candidates.push(match[1]);
1007
+ const machineName = match[1];
1008
+ if (!machineName) continue;
1009
+ const aliasText = match[2] ?? "";
1010
+ const aliasTokens = /* @__PURE__ */ new Set();
1011
+ for (const t of machineName.split(/[^a-z0-9]+/)) {
1012
+ if (t) aliasTokens.add(t);
1013
+ }
1014
+ for (const t of aliasText.toLowerCase().split(/[^a-z0-9]+/)) {
1015
+ if (t) aliasTokens.add(t);
1016
+ }
1017
+ for (const t of [...aliasTokens]) {
1018
+ if (t.length > 3 && t.endsWith("s")) aliasTokens.add(t.slice(0, -1));
1019
+ }
1020
+ candidates.push({ name: machineName, aliasTokens });
904
1021
  }
905
1022
  const lastUser = [...messages].reverse().find((m) => m.role === "user");
906
1023
  const userText = typeof lastUser?.content === "string" ? lastUser.content.toLowerCase() : "";
907
1024
  const userTokens = new Set(
908
1025
  userText.split(/[^a-z0-9_]+/).filter((t) => t.length > 1)
909
1026
  );
910
- let chosen = candidates[0];
1027
+ for (const t of [...userTokens]) {
1028
+ if (t.length > 3 && t.endsWith("s")) userTokens.add(t.slice(0, -1));
1029
+ }
1030
+ let chosen = candidates[0]?.name;
911
1031
  let bestScore = -1;
912
- for (const name of candidates) {
913
- const score = name.split(/[^a-z0-9]+/).reduce((acc, tok) => acc + (tok && userTokens.has(tok) ? 1 : 0), 0);
1032
+ for (const cand of candidates) {
1033
+ let score = 0;
1034
+ for (const tok of cand.aliasTokens) {
1035
+ if (userTokens.has(tok)) score += 1;
1036
+ }
914
1037
  if (score > bestScore) {
915
1038
  bestScore = score;
916
- chosen = name;
1039
+ chosen = cand.name;
917
1040
  }
918
1041
  }
919
1042
  const attempts = [];
@@ -934,6 +1057,103 @@ var MemoryLLMAdapter = class {
934
1057
  );
935
1058
  }
936
1059
  };
1060
+ function pickActionTool(userText, actionTools) {
1061
+ if (actionTools.length === 0 || !userText) return null;
1062
+ const userTokens = new Set(
1063
+ userText.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2)
1064
+ );
1065
+ const ACTION_VERBS = /* @__PURE__ */ new Set([
1066
+ "complete",
1067
+ "finish",
1068
+ "done",
1069
+ "close",
1070
+ "start",
1071
+ "begin",
1072
+ "resume",
1073
+ "clone",
1074
+ "copy",
1075
+ "duplicate",
1076
+ "cancel",
1077
+ "abort",
1078
+ "archive",
1079
+ "restore",
1080
+ "approve",
1081
+ "reject",
1082
+ "assign",
1083
+ "unassign",
1084
+ "export",
1085
+ "import",
1086
+ "send",
1087
+ "notify",
1088
+ "publish",
1089
+ "unpublish",
1090
+ "mark",
1091
+ "delete",
1092
+ "remove",
1093
+ "purge",
1094
+ "destroy",
1095
+ "erase"
1096
+ ]);
1097
+ const hasActionVerb = [...userTokens].some((t) => ACTION_VERBS.has(t));
1098
+ if (!hasActionVerb) return null;
1099
+ let best = null;
1100
+ let bestScore = 0;
1101
+ for (const tool of actionTools) {
1102
+ const nameTokens = tool.name.replace(/^action_/, "").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2);
1103
+ let score = 0;
1104
+ for (const tok of nameTokens) {
1105
+ if (!userTokens.has(tok)) continue;
1106
+ score += ACTION_VERBS.has(tok) ? 3 : 1;
1107
+ }
1108
+ if (score > bestScore) {
1109
+ bestScore = score;
1110
+ best = tool;
1111
+ }
1112
+ }
1113
+ return bestScore >= 3 ? best : null;
1114
+ }
1115
+ function extractRecordIdFromMessages(messages, userText) {
1116
+ const userTokens = userText.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2);
1117
+ for (let i = messages.length - 1; i >= 0; i--) {
1118
+ const m = messages[i];
1119
+ if (m.role !== "tool" || !Array.isArray(m.content)) continue;
1120
+ const parts = m.content;
1121
+ for (const part of parts) {
1122
+ if (part?.toolName !== "query_data") continue;
1123
+ const raw = part.output && typeof part.output === "object" && "value" in part.output ? part.output.value : part.result;
1124
+ let payload = {};
1125
+ if (typeof raw === "string") {
1126
+ try {
1127
+ payload = JSON.parse(raw);
1128
+ } catch {
1129
+ }
1130
+ } else if (raw && typeof raw === "object") {
1131
+ payload = raw;
1132
+ }
1133
+ const records = payload.records ?? [];
1134
+ if (records.length === 0) continue;
1135
+ let bestId;
1136
+ let bestScore = -1;
1137
+ for (const rec of records) {
1138
+ if (!rec || typeof rec !== "object") continue;
1139
+ const id = rec.id;
1140
+ if (typeof id !== "string" && typeof id !== "number") continue;
1141
+ const hay = Object.values(rec).filter((v) => typeof v === "string").join(" ").toLowerCase();
1142
+ const hayTokens = hay.split(/[^a-z0-9]+/).filter(Boolean);
1143
+ let score = 0;
1144
+ for (const ut of userTokens) {
1145
+ if (hayTokens.includes(ut)) score += 1;
1146
+ }
1147
+ if (score > bestScore) {
1148
+ bestScore = score;
1149
+ bestId = String(id);
1150
+ }
1151
+ }
1152
+ return bestId ?? String(records[0].id);
1153
+ }
1154
+ }
1155
+ return void 0;
1156
+ }
937
1157
 
938
1158
  // src/tools/tool-registry.ts
939
1159
  var ToolRegistry = class {
@@ -1177,12 +1397,20 @@ function finishPart(result) {
1177
1397
  }
1178
1398
  var _AIService = class _AIService {
1179
1399
  constructor(config = {}) {
1400
+ /**
1401
+ * Map of tool-name → dispatcher used to re-run an approved pending
1402
+ * action. Populated by `registerActionsAsTools()` when action
1403
+ * approval is enabled. Kept private because callers should go
1404
+ * through `approvePendingAction()`.
1405
+ */
1406
+ this.pendingDispatchers = /* @__PURE__ */ new Map();
1180
1407
  this.adapter = config.adapter ?? new MemoryLLMAdapter();
1181
1408
  this.logger = config.logger ?? createLogger({ level: "info", format: "pretty" });
1182
1409
  this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
1183
1410
  this.conversationService = config.conversationService ?? new InMemoryConversationService();
1184
1411
  this.modelRegistry = config.modelRegistry;
1185
1412
  this.traceRecorder = config.traceRecorder ?? new NullTraceRecorder();
1413
+ this.dataEngine = config.dataEngine;
1186
1414
  this.logger.info(
1187
1415
  `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}, models=${this.modelRegistry?.size ?? 0}`
1188
1416
  );
@@ -1297,6 +1525,13 @@ var _AIService = class _AIService {
1297
1525
  * maximum number of iterations (`maxIterations`) is reached.
1298
1526
  */
1299
1527
  async chatWithTools(messages, options) {
1528
+ return this.instrument(
1529
+ "chat_with_tools",
1530
+ options,
1531
+ () => this.chatWithToolsImpl(messages, options)
1532
+ );
1533
+ }
1534
+ async chatWithToolsImpl(messages, options) {
1300
1535
  const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
1301
1536
  const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
1302
1537
  const registeredTools = this.toolRegistry.getAll();
@@ -1449,11 +1684,144 @@ var _AIService = class _AIService {
1449
1684
  yield textDeltaPart("stream", result.content);
1450
1685
  yield finishPart(result);
1451
1686
  }
1687
+ // ── HITL: pending-action queue ─────────────────────────────────
1688
+ /**
1689
+ * Register a dispatcher callback for a tool. Called by
1690
+ * `registerActionsAsTools()` when action approval is enabled so the
1691
+ * approval handler can re-run the exact same code path the LLM
1692
+ * would have triggered.
1693
+ */
1694
+ registerPendingActionDispatcher(toolName, dispatch) {
1695
+ this.pendingDispatchers.set(toolName, dispatch);
1696
+ }
1697
+ async proposePendingAction(input) {
1698
+ if (!this.dataEngine) {
1699
+ throw new Error("proposePendingAction requires a dataEngine \u2014 wire it via AIServiceConfig.");
1700
+ }
1701
+ const id = `pa_${cryptoRandomId()}`;
1702
+ const row = {
1703
+ id,
1704
+ conversation_id: input.conversationId ?? null,
1705
+ message_id: input.messageId ?? null,
1706
+ object_name: input.objectName,
1707
+ action_name: input.actionName,
1708
+ tool_name: input.toolName,
1709
+ tool_input: JSON.stringify(input.toolInput ?? {}),
1710
+ status: "pending",
1711
+ proposed_by: input.proposedBy ?? "ai_agent",
1712
+ proposed_at: (/* @__PURE__ */ new Date()).toISOString()
1713
+ };
1714
+ await this.dataEngine.insert("ai_pending_actions", row);
1715
+ this.logger.info(
1716
+ `[AI] pending action proposed: ${id} (${input.toolName} on ${input.objectName})`
1717
+ );
1718
+ return { id };
1719
+ }
1720
+ async approvePendingAction(id, actorId) {
1721
+ if (!this.dataEngine) {
1722
+ throw new Error("approvePendingAction requires a dataEngine.");
1723
+ }
1724
+ const row = await this.loadPendingRow(id);
1725
+ if (row.status !== "pending") {
1726
+ throw new Error(`pending action ${id} is already ${row.status}`);
1727
+ }
1728
+ const dispatch = this.pendingDispatchers.get(row.tool_name);
1729
+ if (!dispatch) {
1730
+ throw new Error(
1731
+ `no dispatcher registered for tool '${row.tool_name}' \u2014 was the AI plugin restarted without re-registering actions?`
1732
+ );
1733
+ }
1734
+ await this.dataEngine.update(
1735
+ "ai_pending_actions",
1736
+ {
1737
+ id,
1738
+ status: "approved",
1739
+ decided_by: actorId,
1740
+ decided_at: (/* @__PURE__ */ new Date()).toISOString()
1741
+ },
1742
+ { where: { id } }
1743
+ );
1744
+ let parsed = {};
1745
+ try {
1746
+ parsed = row.tool_input ? JSON.parse(row.tool_input) : {};
1747
+ } catch {
1748
+ parsed = {};
1749
+ }
1750
+ try {
1751
+ const out = await dispatch(parsed);
1752
+ await this.dataEngine.update(
1753
+ "ai_pending_actions",
1754
+ { id, status: "executed", result: JSON.stringify(out ?? null) },
1755
+ { where: { id } }
1756
+ );
1757
+ this.logger.info(`[AI] pending action ${id} executed by ${actorId}`);
1758
+ return { status: "executed", result: out };
1759
+ } catch (err) {
1760
+ const msg = err instanceof Error ? err.message : String(err);
1761
+ await this.dataEngine.update(
1762
+ "ai_pending_actions",
1763
+ { id, status: "failed", error: msg },
1764
+ { where: { id } }
1765
+ );
1766
+ this.logger.warn(`[AI] pending action ${id} failed after approval: ${msg}`);
1767
+ return { status: "failed", error: msg };
1768
+ }
1769
+ }
1770
+ async rejectPendingAction(id, actorId, reason) {
1771
+ if (!this.dataEngine) {
1772
+ throw new Error("rejectPendingAction requires a dataEngine.");
1773
+ }
1774
+ const row = await this.loadPendingRow(id);
1775
+ if (row.status !== "pending") {
1776
+ throw new Error(`pending action ${id} is already ${row.status}`);
1777
+ }
1778
+ await this.dataEngine.update(
1779
+ "ai_pending_actions",
1780
+ {
1781
+ id,
1782
+ status: "rejected",
1783
+ decided_by: actorId,
1784
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
1785
+ rejection_reason: reason ?? null
1786
+ },
1787
+ { where: { id } }
1788
+ );
1789
+ this.logger.info(`[AI] pending action ${id} rejected by ${actorId}`);
1790
+ }
1791
+ async listPendingActions(filter) {
1792
+ if (!this.dataEngine) return [];
1793
+ const where = {};
1794
+ if (filter?.status) {
1795
+ where.status = Array.isArray(filter.status) ? { in: filter.status } : filter.status;
1796
+ }
1797
+ if (filter?.conversationId) where.conversation_id = filter.conversationId;
1798
+ if (filter?.objectName) where.object_name = filter.objectName;
1799
+ const rows = await this.dataEngine.find("ai_pending_actions", {
1800
+ where,
1801
+ limit: filter?.limit ?? 100,
1802
+ orderBy: [{ field: "proposed_at", order: "desc" }]
1803
+ });
1804
+ return rows;
1805
+ }
1806
+ async loadPendingRow(id) {
1807
+ const rows = await this.dataEngine.find("ai_pending_actions", {
1808
+ where: { id },
1809
+ limit: 1
1810
+ });
1811
+ const row = rows[0];
1812
+ if (!row) throw new Error(`pending action ${id} not found`);
1813
+ return row;
1814
+ }
1452
1815
  };
1453
1816
  // ── Tool Call Loop ────────────────────────────────────────────
1454
1817
  /** Default maximum iterations for the tool call loop. */
1455
1818
  _AIService.DEFAULT_MAX_ITERATIONS = 10;
1456
1819
  var AIService = _AIService;
1820
+ function cryptoRandomId() {
1821
+ const g = globalThis;
1822
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID();
1823
+ return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1824
+ }
1457
1825
 
1458
1826
  // src/stream/vercel-stream-encoder.ts
1459
1827
  function sse(data) {
@@ -2338,6 +2706,132 @@ function buildToolRoutes(aiService, logger) {
2338
2706
  ];
2339
2707
  }
2340
2708
 
2709
+ // src/routes/pending-action-routes.ts
2710
+ function buildPendingActionRoutes(aiService, logger) {
2711
+ const supported = typeof aiService.listPendingActions === "function" && typeof aiService.approvePendingAction === "function" && typeof aiService.rejectPendingAction === "function";
2712
+ if (!supported) {
2713
+ logger.warn(
2714
+ "[AI] HITL pending-action methods not implemented on AI service \u2014 routes return 501."
2715
+ );
2716
+ }
2717
+ const notImpl = () => ({
2718
+ status: 501,
2719
+ body: { error: "Pending-action queue not available (dataEngine not wired)" }
2720
+ });
2721
+ return [
2722
+ // ── List pending actions ───────────────────────────────────────
2723
+ {
2724
+ method: "GET",
2725
+ path: "/api/v1/ai/pending-actions",
2726
+ description: "List pending actions in the HITL approval queue",
2727
+ auth: true,
2728
+ permissions: ["ai:read"],
2729
+ handler: async (req) => {
2730
+ if (!supported) return notImpl();
2731
+ try {
2732
+ const query = req.query ?? {};
2733
+ const status = typeof query.status === "string" ? query.status : void 0;
2734
+ const conversationId = typeof query.conversationId === "string" ? query.conversationId : void 0;
2735
+ const limitRaw = query.limit;
2736
+ const limit = typeof limitRaw === "string" ? Number(limitRaw) : void 0;
2737
+ const rows = await aiService.listPendingActions({
2738
+ status,
2739
+ conversationId,
2740
+ limit: Number.isFinite(limit) ? limit : void 0
2741
+ });
2742
+ return { status: 200, body: { items: rows, total: rows.length } };
2743
+ } catch (err) {
2744
+ logger.error(
2745
+ "[AI Route] /pending-actions list error",
2746
+ err instanceof Error ? err : void 0
2747
+ );
2748
+ return { status: 500, body: { error: "Failed to list pending actions" } };
2749
+ }
2750
+ }
2751
+ },
2752
+ // ── Get a single pending action ────────────────────────────────
2753
+ {
2754
+ method: "GET",
2755
+ path: "/api/v1/ai/pending-actions/:id",
2756
+ description: "Get a single pending action by id",
2757
+ auth: true,
2758
+ permissions: ["ai:read"],
2759
+ handler: async (req) => {
2760
+ if (!supported) return notImpl();
2761
+ const id = req.params?.id;
2762
+ if (!id) return { status: 400, body: { error: "id is required" } };
2763
+ try {
2764
+ const rows = await aiService.listPendingActions({});
2765
+ const found = rows.find((r) => r.id === id);
2766
+ if (!found) return { status: 404, body: { error: `Pending action ${id} not found` } };
2767
+ return { status: 200, body: found };
2768
+ } catch (err) {
2769
+ logger.error(
2770
+ "[AI Route] /pending-actions/:id error",
2771
+ err instanceof Error ? err : void 0
2772
+ );
2773
+ return { status: 500, body: { error: "Failed to load pending action" } };
2774
+ }
2775
+ }
2776
+ },
2777
+ // ── Approve & execute ──────────────────────────────────────────
2778
+ {
2779
+ method: "POST",
2780
+ path: "/api/v1/ai/pending-actions/:id/approve",
2781
+ description: "Approve a pending action and execute it immediately",
2782
+ auth: true,
2783
+ permissions: ["ai:approve"],
2784
+ handler: async (req) => {
2785
+ if (!supported) return notImpl();
2786
+ const id = req.params?.id;
2787
+ if (!id) return { status: 400, body: { error: "id is required" } };
2788
+ const actorId = req.user?.id ?? "system";
2789
+ try {
2790
+ const outcome = await aiService.approvePendingAction(id, actorId);
2791
+ const httpStatus = outcome.status === "executed" ? 200 : 500;
2792
+ return { status: httpStatus, body: outcome };
2793
+ } catch (err) {
2794
+ const msg = err instanceof Error ? err.message : String(err);
2795
+ logger.error("[AI Route] /pending-actions/:id/approve error", err instanceof Error ? err : void 0);
2796
+ if (/not found/i.test(msg)) return { status: 404, body: { error: msg } };
2797
+ if (/already|not pending|no dispatcher/i.test(msg)) {
2798
+ return { status: 409, body: { error: msg } };
2799
+ }
2800
+ return { status: 500, body: { error: msg } };
2801
+ }
2802
+ }
2803
+ },
2804
+ // ── Reject ─────────────────────────────────────────────────────
2805
+ {
2806
+ method: "POST",
2807
+ path: "/api/v1/ai/pending-actions/:id/reject",
2808
+ description: "Reject a pending action (will not be executed)",
2809
+ auth: true,
2810
+ permissions: ["ai:approve"],
2811
+ handler: async (req) => {
2812
+ if (!supported) return notImpl();
2813
+ const id = req.params?.id;
2814
+ if (!id) return { status: 400, body: { error: "id is required" } };
2815
+ const actorId = req.user?.id ?? "system";
2816
+ const body = req.body ?? {};
2817
+ const reason = typeof body.reason === "string" ? body.reason : void 0;
2818
+ try {
2819
+ await aiService.rejectPendingAction(id, actorId, reason);
2820
+ return { status: 200, body: { status: "rejected", id } };
2821
+ } catch (err) {
2822
+ const msg = err instanceof Error ? err.message : String(err);
2823
+ logger.error("[AI Route] /pending-actions/:id/reject error", err instanceof Error ? err : void 0);
2824
+ if (/not found/i.test(msg)) return { status: 404, body: { error: msg } };
2825
+ if (/already|not pending/i.test(msg)) {
2826
+ return { status: 409, body: { error: msg } };
2827
+ }
2828
+ return { status: 500, body: { error: msg } };
2829
+ }
2830
+ }
2831
+ }
2832
+ ];
2833
+ }
2834
+
2341
2835
  // src/conversation/objectql-conversation-service.ts
2342
2836
  import { randomUUID as randomUUID2 } from "crypto";
2343
2837
  var CONVERSATIONS_OBJECT = "ai_conversations";
@@ -2808,6 +3302,438 @@ var AiTraceObject = ObjectSchema3.create({
2808
3302
  }
2809
3303
  });
2810
3304
 
3305
+ // src/objects/ai-pending-action.object.ts
3306
+ import { ObjectSchema as ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
3307
+ var AiPendingActionObject = ObjectSchema4.create({
3308
+ name: "ai_pending_actions",
3309
+ label: "AI Pending Action",
3310
+ pluralLabel: "AI Pending Actions",
3311
+ icon: "shield-check",
3312
+ isSystem: true,
3313
+ description: "Queue of AI-proposed action invocations awaiting human approval",
3314
+ fields: {
3315
+ id: Field4.text({
3316
+ label: "Request ID",
3317
+ required: true,
3318
+ readonly: true
3319
+ }),
3320
+ conversation_id: Field4.lookup("ai_conversations", {
3321
+ label: "Conversation",
3322
+ required: false,
3323
+ description: "Conversation that produced this proposal, if any"
3324
+ }),
3325
+ message_id: Field4.lookup("ai_messages", {
3326
+ label: "Message",
3327
+ required: false,
3328
+ description: "Assistant message containing the proposed tool call"
3329
+ }),
3330
+ object_name: Field4.text({
3331
+ label: "Object",
3332
+ required: true,
3333
+ maxLength: 128,
3334
+ description: 'Target object name (e.g. "task")'
3335
+ }),
3336
+ action_name: Field4.text({
3337
+ label: "Action",
3338
+ required: true,
3339
+ maxLength: 128,
3340
+ description: 'Declarative action name (e.g. "delete_task")'
3341
+ }),
3342
+ tool_name: Field4.text({
3343
+ label: "Tool",
3344
+ required: true,
3345
+ maxLength: 128,
3346
+ description: 'AI tool name exposed to the LLM (e.g. "action_delete_task")'
3347
+ }),
3348
+ tool_input: Field4.textarea({
3349
+ label: "Tool Input",
3350
+ required: true,
3351
+ description: "JSON-serialised tool arguments the LLM passed"
3352
+ }),
3353
+ status: Field4.select({
3354
+ label: "Status",
3355
+ required: true,
3356
+ defaultValue: "pending",
3357
+ options: [
3358
+ { label: "Pending Approval", value: "pending" },
3359
+ { label: "Approved (queued)", value: "approved" },
3360
+ { label: "Executed", value: "executed" },
3361
+ { label: "Failed", value: "failed" },
3362
+ { label: "Rejected", value: "rejected" }
3363
+ ]
3364
+ }),
3365
+ result: Field4.textarea({
3366
+ label: "Execution Result",
3367
+ required: false,
3368
+ description: "JSON-serialised result from the action when executed"
3369
+ }),
3370
+ error: Field4.textarea({
3371
+ label: "Error",
3372
+ required: false,
3373
+ description: "Error message when status=failed"
3374
+ }),
3375
+ rejection_reason: Field4.textarea({
3376
+ label: "Rejection Reason",
3377
+ required: false,
3378
+ description: "Why the reviewer rejected (shown back to the LLM)"
3379
+ }),
3380
+ proposed_by: Field4.text({
3381
+ label: "Proposed By",
3382
+ required: false,
3383
+ maxLength: 128,
3384
+ description: "Principal id of the AI agent that proposed the action"
3385
+ }),
3386
+ decided_by: Field4.text({
3387
+ label: "Decided By",
3388
+ required: false,
3389
+ maxLength: 128,
3390
+ description: "User id of the human who approved/rejected"
3391
+ }),
3392
+ proposed_at: Field4.datetime({
3393
+ label: "Proposed At",
3394
+ required: true,
3395
+ defaultValue: "NOW()",
3396
+ readonly: true
3397
+ }),
3398
+ decided_at: Field4.datetime({
3399
+ label: "Decided At",
3400
+ required: false,
3401
+ description: "When approve/reject happened"
3402
+ })
3403
+ },
3404
+ indexes: [
3405
+ { fields: ["status"] },
3406
+ { fields: ["conversation_id"] },
3407
+ { fields: ["object_name"] },
3408
+ { fields: ["proposed_at"] }
3409
+ ],
3410
+ actions: [
3411
+ {
3412
+ name: "approve_pending_action",
3413
+ label: "Approve",
3414
+ type: "api",
3415
+ target: "/api/v1/ai/pending-actions/{recordId}/approve",
3416
+ method: "POST",
3417
+ locations: ["list_item", "record_header"],
3418
+ variant: "primary",
3419
+ confirmText: "Approve and execute this action now?",
3420
+ successMessage: "Action approved and executed.",
3421
+ // The approval click is the operator's authorisation gesture —
3422
+ // the LLM must not be allowed to bypass HITL by approving itself.
3423
+ aiExposed: false
3424
+ },
3425
+ {
3426
+ name: "reject_pending_action",
3427
+ label: "Reject",
3428
+ type: "api",
3429
+ target: "/api/v1/ai/pending-actions/{recordId}/reject",
3430
+ method: "POST",
3431
+ locations: ["list_item", "record_header"],
3432
+ variant: "danger",
3433
+ confirmText: "Reject this pending action? It will not be executed.",
3434
+ successMessage: "Action rejected.",
3435
+ aiExposed: false
3436
+ }
3437
+ ],
3438
+ enable: {
3439
+ trackHistory: false,
3440
+ searchable: false,
3441
+ apiEnabled: true,
3442
+ apiMethods: ["get", "list"],
3443
+ trash: false,
3444
+ mru: false
3445
+ }
3446
+ });
3447
+
3448
+ // src/views/ai-trace.view.ts
3449
+ import { defineView } from "@objectstack/spec";
3450
+ var AiTraceView = defineView({
3451
+ list: {
3452
+ type: "grid",
3453
+ data: { provider: "object", object: "ai_traces" },
3454
+ columns: [
3455
+ { field: "created_at", label: "Time" },
3456
+ { field: "operation" },
3457
+ { field: "model" },
3458
+ { field: "agent_id", label: "Agent" },
3459
+ { field: "latency_ms", label: "Latency (ms)" },
3460
+ { field: "total_tokens", label: "Tokens" },
3461
+ { field: "cost_total", label: "Cost" },
3462
+ { field: "status" }
3463
+ ],
3464
+ sort: [{ field: "created_at", order: "desc" }],
3465
+ pagination: { pageSize: 50 },
3466
+ searchableFields: ["conversation_id", "agent_id", "model", "error"],
3467
+ filterableFields: ["operation", "model", "status"]
3468
+ },
3469
+ listViews: {
3470
+ errors: {
3471
+ label: "Errors",
3472
+ type: "grid",
3473
+ data: { provider: "object", object: "ai_traces" },
3474
+ columns: [
3475
+ { field: "created_at", label: "Time" },
3476
+ { field: "operation" },
3477
+ { field: "model" },
3478
+ { field: "latency_ms", label: "Latency (ms)" },
3479
+ { field: "error" }
3480
+ ],
3481
+ filter: [
3482
+ { field: "status", operator: "=", value: "error" }
3483
+ ],
3484
+ sort: [{ field: "created_at", order: "desc" }]
3485
+ },
3486
+ by_model: {
3487
+ label: "By Model",
3488
+ type: "grid",
3489
+ data: { provider: "object", object: "ai_traces" },
3490
+ columns: [
3491
+ { field: "model" },
3492
+ { field: "operation" },
3493
+ { field: "latency_ms", label: "Latency (ms)" },
3494
+ { field: "total_tokens", label: "Tokens" },
3495
+ { field: "cost_total", label: "Cost" },
3496
+ { field: "status" },
3497
+ { field: "created_at", label: "Time" }
3498
+ ],
3499
+ grouping: { fields: [{ field: "model" }] },
3500
+ sort: [{ field: "created_at", order: "desc" }]
3501
+ }
3502
+ }
3503
+ });
3504
+
3505
+ // src/views/ai-pending-action.view.ts
3506
+ import { defineView as defineView2 } from "@objectstack/spec";
3507
+ var AiPendingActionView = defineView2({
3508
+ list: {
3509
+ type: "grid",
3510
+ data: { provider: "object", object: "ai_pending_actions" },
3511
+ columns: [
3512
+ { field: "proposed_at", label: "Proposed", type: "datetime-relative", width: 140 },
3513
+ { field: "status", width: 130 },
3514
+ { field: "object_name", label: "Object", width: 140 },
3515
+ { field: "action_name", label: "Action", width: 180 },
3516
+ { field: "proposed_by", label: "Proposed by", width: 160 },
3517
+ { field: "decided_by", label: "Decided by", width: 160 },
3518
+ { field: "decided_at", label: "Decided", type: "datetime-relative", width: 140 }
3519
+ ],
3520
+ sort: [{ field: "proposed_at", order: "desc" }],
3521
+ pagination: { pageSize: 50 },
3522
+ searchableFields: ["action_name", "object_name", "tool_name", "proposed_by"],
3523
+ filterableFields: ["status", "object_name", "action_name"],
3524
+ rowActions: ["approve_pending_action", "reject_pending_action"],
3525
+ // Click a row → open the detail drawer instead of navigating to a page.
3526
+ navigation: { mode: "drawer", view: "detail", width: "640px" },
3527
+ rowColor: {
3528
+ field: "status",
3529
+ mapping: {
3530
+ pending: "amber",
3531
+ approved: "blue",
3532
+ executed: "green",
3533
+ failed: "red",
3534
+ rejected: "gray"
3535
+ }
3536
+ }
3537
+ },
3538
+ form: {
3539
+ type: "drawer",
3540
+ data: { provider: "object", object: "ai_pending_actions" },
3541
+ sections: [
3542
+ {
3543
+ label: "Proposal",
3544
+ columns: 2,
3545
+ fields: [
3546
+ { field: "status", readonly: true },
3547
+ { field: "proposed_at", readonly: true, widget: "datetime-relative" },
3548
+ { field: "object_name", label: "Target object", readonly: true },
3549
+ { field: "action_name", label: "Action", readonly: true },
3550
+ { field: "tool_name", label: "Tool exposed to LLM", readonly: true, colSpan: 2 },
3551
+ { field: "proposed_by", label: "Proposed by (AI agent)", readonly: true, colSpan: 2 }
3552
+ ]
3553
+ },
3554
+ {
3555
+ label: "Tool input",
3556
+ collapsible: true,
3557
+ columns: 1,
3558
+ fields: [
3559
+ {
3560
+ field: "tool_input",
3561
+ label: "Arguments the LLM sent",
3562
+ readonly: true,
3563
+ widget: "json",
3564
+ colSpan: 1,
3565
+ helpText: "Pretty-printed JSON. Review carefully before approving \u2014 this is the exact payload that will be re-played against the handler."
3566
+ }
3567
+ ]
3568
+ },
3569
+ {
3570
+ label: "Conversation context",
3571
+ collapsible: true,
3572
+ collapsed: true,
3573
+ columns: 2,
3574
+ fields: [
3575
+ // Both are lookups — Studio renders them as links to the related
3576
+ // ai_conversations / ai_messages record so operators can jump to
3577
+ // the full transcript for context.
3578
+ { field: "conversation_id", label: "Conversation", readonly: true },
3579
+ { field: "message_id", label: "Assistant message", readonly: true }
3580
+ ]
3581
+ },
3582
+ {
3583
+ label: "Decision",
3584
+ collapsible: true,
3585
+ // Only meaningful once the row has been actioned; left collapsed
3586
+ // by default for pending rows so the eye lands on the proposal.
3587
+ collapsed: true,
3588
+ columns: 2,
3589
+ fields: [
3590
+ { field: "decided_by", label: "Decided by", readonly: true },
3591
+ { field: "decided_at", label: "Decided", readonly: true, widget: "datetime-relative" },
3592
+ {
3593
+ field: "rejection_reason",
3594
+ label: "Rejection reason",
3595
+ readonly: true,
3596
+ colSpan: 2,
3597
+ visibleOn: 'record.status == "rejected"'
3598
+ },
3599
+ {
3600
+ field: "result",
3601
+ label: "Execution result",
3602
+ readonly: true,
3603
+ widget: "json",
3604
+ colSpan: 2,
3605
+ visibleOn: 'record.status == "executed"'
3606
+ },
3607
+ {
3608
+ field: "error",
3609
+ label: "Error",
3610
+ readonly: true,
3611
+ colSpan: 2,
3612
+ visibleOn: 'record.status == "failed"'
3613
+ }
3614
+ ]
3615
+ }
3616
+ ]
3617
+ },
3618
+ formViews: {
3619
+ detail: {
3620
+ type: "drawer",
3621
+ data: { provider: "object", object: "ai_pending_actions" },
3622
+ // Mirror of the default form. Named separately so the list's
3623
+ // `navigation.view: 'detail'` resolves explicitly — Studio falls back
3624
+ // to `form` if a named view isn't registered, but being explicit
3625
+ // makes the wiring legible to readers of the metadata.
3626
+ sections: [
3627
+ {
3628
+ label: "Proposal",
3629
+ columns: 2,
3630
+ fields: [
3631
+ { field: "status", readonly: true },
3632
+ { field: "proposed_at", readonly: true, widget: "datetime-relative" },
3633
+ { field: "object_name", label: "Target object", readonly: true },
3634
+ { field: "action_name", label: "Action", readonly: true },
3635
+ { field: "tool_name", label: "Tool exposed to LLM", readonly: true, colSpan: 2 },
3636
+ { field: "proposed_by", label: "Proposed by (AI agent)", readonly: true, colSpan: 2 }
3637
+ ]
3638
+ },
3639
+ {
3640
+ label: "Tool input",
3641
+ collapsible: true,
3642
+ columns: 1,
3643
+ fields: [
3644
+ { field: "tool_input", label: "Arguments the LLM sent", readonly: true, widget: "json" }
3645
+ ]
3646
+ },
3647
+ {
3648
+ label: "Conversation context",
3649
+ collapsible: true,
3650
+ collapsed: true,
3651
+ columns: 2,
3652
+ fields: [
3653
+ { field: "conversation_id", label: "Conversation", readonly: true },
3654
+ { field: "message_id", label: "Assistant message", readonly: true }
3655
+ ]
3656
+ },
3657
+ {
3658
+ label: "Decision",
3659
+ collapsible: true,
3660
+ collapsed: true,
3661
+ columns: 2,
3662
+ fields: [
3663
+ { field: "decided_by", label: "Decided by", readonly: true },
3664
+ { field: "decided_at", label: "Decided", readonly: true, widget: "datetime-relative" },
3665
+ { field: "rejection_reason", label: "Rejection reason", readonly: true, colSpan: 2, visibleOn: 'record.status == "rejected"' },
3666
+ { field: "result", label: "Execution result", readonly: true, widget: "json", colSpan: 2, visibleOn: 'record.status == "executed"' },
3667
+ { field: "error", label: "Error", readonly: true, colSpan: 2, visibleOn: 'record.status == "failed"' }
3668
+ ]
3669
+ }
3670
+ ]
3671
+ }
3672
+ },
3673
+ listViews: {
3674
+ pending: {
3675
+ label: "Pending",
3676
+ type: "grid",
3677
+ data: { provider: "object", object: "ai_pending_actions" },
3678
+ columns: [
3679
+ { field: "proposed_at", label: "Proposed", type: "datetime-relative", width: 140 },
3680
+ { field: "object_name", label: "Object", width: 140 },
3681
+ { field: "action_name", label: "Action", width: 180 },
3682
+ { field: "proposed_by", label: "Proposed by", width: 160 },
3683
+ { field: "tool_name", label: "Tool", width: 200 }
3684
+ ],
3685
+ filter: [{ field: "status", operator: "=", value: "pending" }],
3686
+ sort: [{ field: "proposed_at", order: "desc" }],
3687
+ rowActions: ["approve_pending_action", "reject_pending_action"],
3688
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3689
+ },
3690
+ executed: {
3691
+ label: "Executed",
3692
+ type: "grid",
3693
+ data: { provider: "object", object: "ai_pending_actions" },
3694
+ columns: [
3695
+ { field: "decided_at", label: "Approved", type: "datetime-relative", width: 140 },
3696
+ { field: "object_name", label: "Object", width: 140 },
3697
+ { field: "action_name", label: "Action", width: 180 },
3698
+ { field: "decided_by", label: "Approved by", width: 160 },
3699
+ { field: "proposed_by", label: "Proposed by", width: 160 }
3700
+ ],
3701
+ filter: [{ field: "status", operator: "=", value: "executed" }],
3702
+ sort: [{ field: "decided_at", order: "desc" }],
3703
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3704
+ },
3705
+ rejected: {
3706
+ label: "Rejected",
3707
+ type: "grid",
3708
+ data: { provider: "object", object: "ai_pending_actions" },
3709
+ columns: [
3710
+ { field: "decided_at", label: "Rejected", type: "datetime-relative", width: 140 },
3711
+ { field: "object_name", label: "Object", width: 140 },
3712
+ { field: "action_name", label: "Action", width: 180 },
3713
+ { field: "decided_by", label: "Rejected by", width: 160 },
3714
+ { field: "rejection_reason", label: "Reason", wrap: true }
3715
+ ],
3716
+ filter: [{ field: "status", operator: "=", value: "rejected" }],
3717
+ sort: [{ field: "decided_at", order: "desc" }],
3718
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3719
+ },
3720
+ failed: {
3721
+ label: "Failed",
3722
+ type: "grid",
3723
+ data: { provider: "object", object: "ai_pending_actions" },
3724
+ columns: [
3725
+ { field: "decided_at", label: "When", type: "datetime-relative", width: 140 },
3726
+ { field: "object_name", label: "Object", width: 140 },
3727
+ { field: "action_name", label: "Action", width: 180 },
3728
+ { field: "error", wrap: true }
3729
+ ],
3730
+ filter: [{ field: "status", operator: "=", value: "failed" }],
3731
+ sort: [{ field: "decided_at", order: "desc" }],
3732
+ navigation: { mode: "drawer", view: "detail", width: "640px" }
3733
+ }
3734
+ }
3735
+ });
3736
+
2811
3737
  // src/plugin.ts
2812
3738
  init_data_tools();
2813
3739
  init_metadata_tools();
@@ -2860,8 +3786,11 @@ var SchemaRetriever = class {
2860
3786
  const lines = ["## Schema context (auto-injected)"];
2861
3787
  for (const hit of hits) {
2862
3788
  const obj = hit.object;
2863
- const label = obj.label ? ` \u2014 ${obj.label}` : "";
2864
- lines.push(`### ${obj.name}${label}`);
3789
+ const parts = [];
3790
+ if (obj.label) parts.push(obj.label);
3791
+ if (obj.pluralLabel && obj.pluralLabel !== obj.label) parts.push(`(${obj.pluralLabel})`);
3792
+ const header = parts.length > 0 ? ` \u2014 ${parts.join(" ")}` : "";
3793
+ lines.push(`### ${obj.name}${header}`);
2865
3794
  const fields = Object.entries(obj.fields ?? {}).slice(0, maxFieldsPerObject);
2866
3795
  for (const [name, field] of fields) {
2867
3796
  lines.push(` - ${name}: ${describeField(field)}`);
@@ -2961,17 +3890,17 @@ function describeField(field) {
2961
3890
  // src/tools/query-data.tool.ts
2962
3891
  var QueryPlanSchema = z.object({
2963
3892
  objectName: z.string().min(1).describe('The snake_case object name to query (e.g. "task", "account").'),
2964
- where: z.record(z.string(), z.unknown()).optional().describe(
2965
- 'Filter conditions as key-value pairs. Use MongoDB-style operators for ranges, e.g. {"amount": {"$gt": 100}}.'
3893
+ whereJson: z.string().nullable().describe(
3894
+ 'Filter conditions encoded as a JSON object string. Examples: `{"status":"completed"}`, `{"subject":{"$contains":"Build"}}`, `{"amount":{"$gt":100}}`. Pass null to match all records.'
2966
3895
  ),
2967
- fields: z.array(z.string()).optional().describe("Field names to return. Omit to return all fields."),
3896
+ fields: z.array(z.string()).nullable().describe("Field names to return. Pass null to return all fields."),
2968
3897
  orderBy: z.array(
2969
3898
  z.object({
2970
3899
  field: z.string(),
2971
3900
  order: z.enum(["asc", "desc"])
2972
3901
  })
2973
- ).optional().describe("Sort order. First entry is primary sort key."),
2974
- limit: z.number().int().min(1).max(200).optional().describe("Maximum number of records (default 20, max 200).")
3902
+ ).nullable().describe("Sort order. First entry is primary sort key. Pass null for no sort."),
3903
+ limit: z.number().int().min(1).max(200).nullable().describe("Maximum number of records (default 20, max 200). Pass null for default.")
2975
3904
  });
2976
3905
  var QUERY_DATA_TOOL = {
2977
3906
  name: "query_data",
@@ -2982,10 +3911,6 @@ var QUERY_DATA_TOOL = {
2982
3911
  request: {
2983
3912
  type: "string",
2984
3913
  description: "The natural-language question to answer (paraphrase the user's request if needed for clarity)."
2985
- },
2986
- model: {
2987
- type: "string",
2988
- description: "Optional model id to use for query planning. Defaults to the AI service's default model."
2989
3914
  }
2990
3915
  },
2991
3916
  required: ["request"],
@@ -2996,7 +3921,7 @@ function createQueryDataHandler(ctx) {
2996
3921
  const retriever = new SchemaRetriever(ctx.metadata);
2997
3922
  const maxLimit = ctx.maxLimit ?? 100;
2998
3923
  return async (args) => {
2999
- const { request, model } = args;
3924
+ const { request } = args;
3000
3925
  if (!request || typeof request !== "string") {
3001
3926
  return JSON.stringify({ error: "query_data: `request` is required" });
3002
3927
  }
@@ -3015,14 +3940,13 @@ function createQueryDataHandler(ctx) {
3015
3940
  const planMessages = [
3016
3941
  {
3017
3942
  role: "system",
3018
- 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
3943
+ 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
3019
3944
  },
3020
3945
  { role: "user", content: request }
3021
3946
  ];
3022
3947
  let plan;
3023
3948
  try {
3024
3949
  const generated = await ctx.ai.generateObject(planMessages, QueryPlanSchema, {
3025
- model,
3026
3950
  schemaName: "ObjectQLQueryPlan",
3027
3951
  schemaDescription: "A single ObjectQL find() query to answer the user request."
3028
3952
  });
@@ -3039,15 +3963,34 @@ function createQueryDataHandler(ctx) {
3039
3963
  });
3040
3964
  }
3041
3965
  const limit = Math.min(plan.limit ?? 20, maxLimit);
3966
+ let where;
3967
+ if (plan.whereJson) {
3968
+ try {
3969
+ const parsed = JSON.parse(plan.whereJson);
3970
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
3971
+ where = parsed;
3972
+ } else {
3973
+ return JSON.stringify({
3974
+ plan,
3975
+ error: `whereJson must encode a JSON object, got: ${plan.whereJson}`
3976
+ });
3977
+ }
3978
+ } catch (err) {
3979
+ return JSON.stringify({
3980
+ plan,
3981
+ error: `whereJson is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
3982
+ });
3983
+ }
3984
+ }
3042
3985
  try {
3043
3986
  const records = await ctx.dataEngine.find(plan.objectName, {
3044
- where: plan.where,
3045
- fields: plan.fields,
3046
- orderBy: plan.orderBy,
3987
+ where,
3988
+ fields: plan.fields ?? void 0,
3989
+ orderBy: plan.orderBy ?? void 0,
3047
3990
  limit
3048
3991
  });
3049
3992
  return JSON.stringify({
3050
- plan,
3993
+ plan: { ...plan, where },
3051
3994
  count: records.length,
3052
3995
  records
3053
3996
  });
@@ -3063,6 +4006,402 @@ function registerQueryDataTool(registry, context) {
3063
4006
  registry.register(QUERY_DATA_TOOL, createQueryDataHandler(context));
3064
4007
  }
3065
4008
 
4009
+ // src/tools/action-tools.ts
4010
+ function actionRequiresApproval(action) {
4011
+ return Boolean(
4012
+ action.confirmText || action.mode === "delete" || action.variant === "danger"
4013
+ );
4014
+ }
4015
+ function actionSkipReason(action, ctx) {
4016
+ if (action.aiExposed === false) {
4017
+ return "opted-out via aiExposed:false";
4018
+ }
4019
+ if (action.type === "url" || action.type === "modal" || action.type === "form") {
4020
+ return `type='${action.type}' is UI-only`;
4021
+ }
4022
+ if (action.type !== "script" && action.type !== "api" && action.type !== "flow") {
4023
+ return `type='${action.type}' not supported`;
4024
+ }
4025
+ if (action.type === "script" && !action.target && !action.body) {
4026
+ return "no target or body";
4027
+ }
4028
+ if ((action.type === "api" || action.type === "flow") && !action.target) {
4029
+ return `type='${action.type}' requires a target`;
4030
+ }
4031
+ if (ctx) {
4032
+ if (action.type === "flow" && !ctx.automation) {
4033
+ return "no automation service available";
4034
+ }
4035
+ if (action.type === "api" && !ctx.apiClient && !ctx.apiBaseUrl) {
4036
+ return "no apiClient or apiBaseUrl configured";
4037
+ }
4038
+ }
4039
+ if (actionRequiresApproval(action)) {
4040
+ const approvalReady = ctx?.enableActionApproval === true && Boolean(ctx?.aiService?.proposePendingAction);
4041
+ if (!approvalReady) {
4042
+ if (action.confirmText) return "requires confirmation (confirmText set)";
4043
+ if (action.mode === "delete") return "mode='delete' \u2014 destructive";
4044
+ if (action.variant === "danger") return "variant='danger' \u2014 destructive";
4045
+ }
4046
+ }
4047
+ return null;
4048
+ }
4049
+ function fieldTypeToJsonType(t) {
4050
+ switch (t) {
4051
+ case "number":
4052
+ case "currency":
4053
+ case "percent":
4054
+ case "rating":
4055
+ case "slider":
4056
+ case "autonumber":
4057
+ return "number";
4058
+ case "boolean":
4059
+ case "toggle":
4060
+ return "boolean";
4061
+ case "multiselect":
4062
+ case "checkboxes":
4063
+ case "tags":
4064
+ return "array";
4065
+ default:
4066
+ return "string";
4067
+ }
4068
+ }
4069
+ function resolveParam(param, ownerObject, allObjects) {
4070
+ const fieldRef = param.field;
4071
+ const owner = param.objectOverride && allObjects.get(param.objectOverride) ? allObjects.get(param.objectOverride) : ownerObject;
4072
+ const field = fieldRef ? owner?.fields?.[fieldRef] : void 0;
4073
+ const name = param.name ?? fieldRef;
4074
+ if (!name) return null;
4075
+ const type = param.type ?? field?.type;
4076
+ const jsonType = fieldTypeToJsonType(type);
4077
+ const schema = { type: jsonType };
4078
+ const label = typeof param.label === "string" ? param.label : field?.label;
4079
+ const help = param.helpText ?? field?.description;
4080
+ const description = [label, help].filter(Boolean).join(" \u2014 ") || void 0;
4081
+ if (description) schema.description = description;
4082
+ const optionSource = param.options ?? field?.options;
4083
+ if (Array.isArray(optionSource) && optionSource.length > 0) {
4084
+ const values = optionSource.map((o) => typeof o === "string" ? o : o.value).filter((v) => typeof v === "string");
4085
+ if (values.length > 0) {
4086
+ schema.enum = jsonType === "array" ? void 0 : values;
4087
+ if (jsonType === "array") {
4088
+ schema.items = { type: "string", enum: values };
4089
+ }
4090
+ }
4091
+ } else if (jsonType === "array") {
4092
+ schema.items = { type: "string" };
4093
+ }
4094
+ if (param.defaultValue !== void 0) {
4095
+ schema.default = param.defaultValue;
4096
+ }
4097
+ const required = Boolean(param.required ?? field?.required ?? false);
4098
+ return { name, schema, required };
4099
+ }
4100
+ function buildParametersSchema(action, ownerObject, allObjects) {
4101
+ const properties = {};
4102
+ const required = [];
4103
+ const isRowContext = Array.isArray(action.locations) && action.locations.some((l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related");
4104
+ if (action.objectName && isRowContext) {
4105
+ properties.recordId = {
4106
+ type: "string",
4107
+ description: `The ${action.objectName} record id to act on.`
4108
+ };
4109
+ if (action.recordIdParam || action.recordIdField) {
4110
+ required.push("recordId");
4111
+ }
4112
+ }
4113
+ for (const param of action.params ?? []) {
4114
+ const resolved = resolveParam(param, ownerObject, allObjects);
4115
+ if (!resolved) continue;
4116
+ properties[resolved.name] = resolved.schema;
4117
+ if (resolved.required) required.push(resolved.name);
4118
+ }
4119
+ return {
4120
+ type: "object",
4121
+ properties,
4122
+ ...required.length > 0 ? { required } : {},
4123
+ additionalProperties: false
4124
+ };
4125
+ }
4126
+ function actionToolName(action, prefix = "action_") {
4127
+ return `${prefix}${action.name}`;
4128
+ }
4129
+ function describeAction(action, ownerObject) {
4130
+ const label = typeof action.label === "string" ? action.label : action.name.replace(/_/g, " ");
4131
+ const target = action.objectName ?? ownerObject?.name;
4132
+ const targetLabel = ownerObject?.label ?? target;
4133
+ const parts = [];
4134
+ parts.push(`${label}${targetLabel ? ` \u2014 operates on ${targetLabel}` : ""}.`);
4135
+ if (action.successMessage && typeof action.successMessage === "string") {
4136
+ parts.push(`On success: ${action.successMessage}`);
4137
+ }
4138
+ if (action.mode) parts.push(`Mode: ${action.mode}.`);
4139
+ parts.push(
4140
+ "Use this when the user asks to perform this operation in natural language."
4141
+ );
4142
+ return parts.join(" ");
4143
+ }
4144
+ function actionToToolDefinition(action, ownerObject, allObjects, toolPrefix = "action_") {
4145
+ if (action.aiExposed === false) return null;
4146
+ if (action.type === "url" || action.type === "modal" || action.type === "form") return null;
4147
+ return {
4148
+ name: actionToolName(action, toolPrefix),
4149
+ description: describeAction(action, ownerObject),
4150
+ parameters: buildParametersSchema(action, ownerObject, allObjects)
4151
+ };
4152
+ }
4153
+ function buildHandlerEngineAdapter(engine) {
4154
+ return {
4155
+ update: (object, id, data) => engine.update(object, { ...data, id }, { where: { id } }),
4156
+ insert: (object, data) => engine.insert(object, data),
4157
+ find: (object, where) => engine.find(object, { where }),
4158
+ delete: async (object, ids) => {
4159
+ if (!Array.isArray(ids) || ids.length === 0) return 0;
4160
+ let count = 0;
4161
+ for (const id of ids) {
4162
+ await engine.delete(object, { where: { id } });
4163
+ count++;
4164
+ }
4165
+ return count;
4166
+ }
4167
+ };
4168
+ }
4169
+ function createActionToolHandler(action, ctx) {
4170
+ const principal = ctx.principal ?? { id: "ai_agent", name: "AI Assistant" };
4171
+ const requiresRecord = Array.isArray(action.locations) && action.locations.some(
4172
+ (l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related"
4173
+ );
4174
+ return async (args) => {
4175
+ const objectName = action.objectName;
4176
+ const target = action.target;
4177
+ const result = {
4178
+ ok: false,
4179
+ action: action.name,
4180
+ objectName
4181
+ };
4182
+ if (!objectName) {
4183
+ result.error = "Action has no objectName; cannot dispatch.";
4184
+ return JSON.stringify(result);
4185
+ }
4186
+ if (!target && action.type !== "script") {
4187
+ result.error = "Action has no target.";
4188
+ return JSON.stringify(result);
4189
+ }
4190
+ const recordId = typeof args.recordId === "string" && args.recordId.length > 0 ? args.recordId : void 0;
4191
+ let record;
4192
+ if (requiresRecord) {
4193
+ if (!recordId) {
4194
+ 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).`;
4195
+ return JSON.stringify(result);
4196
+ }
4197
+ try {
4198
+ const found = await ctx.dataEngine.find(objectName, {
4199
+ where: { id: recordId },
4200
+ limit: 1
4201
+ });
4202
+ record = found[0];
4203
+ if (!record) {
4204
+ result.error = `Record ${recordId} not found in ${objectName}.`;
4205
+ return JSON.stringify(result);
4206
+ }
4207
+ result.recordId = recordId;
4208
+ } catch (err) {
4209
+ result.error = `Failed to load record: ${err instanceof Error ? err.message : String(err)}`;
4210
+ return JSON.stringify(result);
4211
+ }
4212
+ }
4213
+ const { recordId: _omit, ...userParams } = args;
4214
+ if (ctx.enableActionApproval && actionRequiresApproval(action) && ctx.aiService?.proposePendingAction) {
4215
+ try {
4216
+ const toolName = `${ctx.toolPrefix ?? "action_"}${action.name}`;
4217
+ const { id } = await ctx.aiService.proposePendingAction({
4218
+ objectName,
4219
+ actionName: action.name,
4220
+ toolName,
4221
+ toolInput: args,
4222
+ proposedBy: principal.id
4223
+ });
4224
+ const pending = {
4225
+ ok: true,
4226
+ action: action.name,
4227
+ objectName,
4228
+ recordId,
4229
+ status: "pending_approval",
4230
+ pendingActionId: id,
4231
+ 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.`
4232
+ };
4233
+ return JSON.stringify(pending);
4234
+ } catch (err) {
4235
+ result.error = `Failed to enqueue approval: ${err instanceof Error ? err.message : String(err)}`;
4236
+ return JSON.stringify(result);
4237
+ }
4238
+ }
4239
+ try {
4240
+ let out;
4241
+ if (action.type === "api") {
4242
+ out = await dispatchApiAction(action, ctx, userParams, record, recordId);
4243
+ } else if (action.type === "flow") {
4244
+ out = await dispatchFlowAction(action, ctx, userParams, record, principal);
4245
+ } else {
4246
+ out = await dispatchScriptAction(action, ctx, userParams, record, principal);
4247
+ }
4248
+ result.ok = true;
4249
+ result.result = out ?? null;
4250
+ const successMsg = typeof action.successMessage === "string" ? action.successMessage : void 0;
4251
+ result.message = successMsg ?? `Action '${action.name}' executed successfully.`;
4252
+ return JSON.stringify(result);
4253
+ } catch (err) {
4254
+ result.error = err instanceof Error ? err.message : String(err);
4255
+ return JSON.stringify(result);
4256
+ }
4257
+ };
4258
+ }
4259
+ async function dispatchScriptAction(action, ctx, params, record, principal) {
4260
+ const engineAdapter = buildHandlerEngineAdapter(ctx.dataEngine);
4261
+ const handlerCtx = { record, user: principal, engine: engineAdapter, params };
4262
+ return await ctx.dataEngine.executeAction?.(action.objectName, action.target, handlerCtx);
4263
+ }
4264
+ function buildApiRequestBody(action, args, record, recordId) {
4265
+ const shape = action.bodyShape;
4266
+ const wrapKey = shape && typeof shape === "object" && "wrap" in shape && typeof shape.wrap === "string" ? shape.wrap : void 0;
4267
+ const body = wrapKey ? { [wrapKey]: { ...args } } : { ...args };
4268
+ if (action.recordIdParam) {
4269
+ const idField = action.recordIdField ?? "id";
4270
+ const idValue = record ? record[idField] : recordId;
4271
+ if (idValue !== void 0) body[action.recordIdParam] = idValue;
4272
+ }
4273
+ if (action.bodyExtra && typeof action.bodyExtra === "object") {
4274
+ Object.assign(body, action.bodyExtra);
4275
+ }
4276
+ return body;
4277
+ }
4278
+ async function dispatchApiAction(action, ctx, params, record, recordId) {
4279
+ const client = ctx.apiClient ?? (ctx.apiBaseUrl ? createFetchApiClient({ baseUrl: ctx.apiBaseUrl, headers: ctx.apiHeaders }) : void 0);
4280
+ if (!client) {
4281
+ throw new Error('No apiClient configured for type:"api" action dispatch.');
4282
+ }
4283
+ const method = action.method ?? "POST";
4284
+ const body = buildApiRequestBody(action, params, record, recordId);
4285
+ return await client.request({
4286
+ url: action.target,
4287
+ method,
4288
+ body: method === "GET" || method === "DELETE" ? void 0 : body,
4289
+ headers: ctx.apiHeaders
4290
+ });
4291
+ }
4292
+ async function dispatchFlowAction(action, ctx, params, record, principal) {
4293
+ if (!ctx.automation) {
4294
+ throw new Error('No automation service available for type:"flow" action dispatch.');
4295
+ }
4296
+ const result = await ctx.automation.execute(action.target, {
4297
+ triggerData: { record, params, user: principal, action: action.name }
4298
+ });
4299
+ if (result && typeof result === "object" && "success" in result && result.success === false) {
4300
+ throw new Error(
4301
+ `Flow '${action.target}' failed: ${result.error ?? "unknown error"}`
4302
+ );
4303
+ }
4304
+ return result;
4305
+ }
4306
+ function createFetchApiClient(options) {
4307
+ const fetchImpl = options.fetch ?? globalThis.fetch;
4308
+ if (!fetchImpl) {
4309
+ throw new Error("createFetchApiClient: no global fetch available; pass options.fetch.");
4310
+ }
4311
+ return {
4312
+ async request({ url, method, body, headers }) {
4313
+ const absolute = /^https?:\/\//.test(url) ? url : `${(options.baseUrl ?? "").replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
4314
+ const res = await fetchImpl(absolute, {
4315
+ method,
4316
+ headers: {
4317
+ "Content-Type": "application/json",
4318
+ ...options.headers ?? {},
4319
+ ...headers ?? {}
4320
+ },
4321
+ body: body ? JSON.stringify(body) : void 0
4322
+ });
4323
+ const text = await res.text();
4324
+ const parsed = text ? safeJsonParse(text) : null;
4325
+ if (!res.ok) {
4326
+ const msg = parsed && typeof parsed === "object" && "error" in parsed ? parsed.error : text;
4327
+ throw new Error(`${method} ${absolute} \u2192 ${res.status}: ${typeof msg === "string" ? msg : JSON.stringify(msg)}`);
4328
+ }
4329
+ return parsed;
4330
+ }
4331
+ };
4332
+ }
4333
+ function safeJsonParse(s) {
4334
+ try {
4335
+ return JSON.parse(s);
4336
+ } catch {
4337
+ return s;
4338
+ }
4339
+ }
4340
+ async function registerActionsAsTools(registry, context) {
4341
+ const objects = await context.metadata.listObjects();
4342
+ const objMap = new Map(
4343
+ objects.filter((o) => Boolean(o?.name)).map((o) => [o.name, o])
4344
+ );
4345
+ const registered = [];
4346
+ const skipped = [];
4347
+ const prefix = context.toolPrefix ?? "action_";
4348
+ for (const obj of objects) {
4349
+ if (!obj?.actions || !Array.isArray(obj.actions)) continue;
4350
+ for (const action of obj.actions) {
4351
+ if (!action || typeof action.name !== "string") continue;
4352
+ const normalized = {
4353
+ ...action,
4354
+ objectName: action.objectName ?? obj.name
4355
+ };
4356
+ const reason = actionSkipReason(normalized, {
4357
+ automation: context.automation,
4358
+ apiClient: context.apiClient,
4359
+ apiBaseUrl: context.apiBaseUrl,
4360
+ enableActionApproval: context.enableActionApproval,
4361
+ aiService: context.aiService
4362
+ });
4363
+ if (reason !== null) {
4364
+ skipped.push({ action: normalized.name, reason });
4365
+ continue;
4366
+ }
4367
+ const definition = actionToToolDefinition(normalized, obj, objMap, prefix);
4368
+ if (!definition) continue;
4369
+ if (registry.has(definition.name)) {
4370
+ skipped.push({ action: normalized.name, reason: "tool name already registered" });
4371
+ continue;
4372
+ }
4373
+ const handler = createActionToolHandler(normalized, context);
4374
+ registry.register(definition, handler);
4375
+ registered.push(definition.name);
4376
+ if (context.enableActionApproval && actionRequiresApproval(normalized) && context.aiService?.registerPendingActionDispatcher) {
4377
+ const bypassCtx = {
4378
+ ...context,
4379
+ enableActionApproval: false
4380
+ };
4381
+ const directHandler = createActionToolHandler(normalized, bypassCtx);
4382
+ context.aiService.registerPendingActionDispatcher(
4383
+ definition.name,
4384
+ async (input) => {
4385
+ const raw = await directHandler(input);
4386
+ let parsed = raw;
4387
+ try {
4388
+ parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
4389
+ } catch {
4390
+ parsed = raw;
4391
+ }
4392
+ if (parsed && typeof parsed === "object" && "ok" in parsed && parsed.ok === false) {
4393
+ const errMsg = parsed.error != null ? String(parsed.error) : "action handler reported failure";
4394
+ throw new Error(errMsg);
4395
+ }
4396
+ return parsed;
4397
+ }
4398
+ );
4399
+ }
4400
+ }
4401
+ }
4402
+ return { registered, skipped };
4403
+ }
4404
+
3066
4405
  // src/agent-runtime.ts
3067
4406
  import { AgentSchema } from "@objectstack/spec/ai";
3068
4407
  var AgentRuntime = class {
@@ -3365,6 +4704,16 @@ var SkillRegistry = class {
3365
4704
  const resolved = [];
3366
4705
  for (const skill of skills) {
3367
4706
  for (const toolName of skill.tools) {
4707
+ if (toolName.endsWith("*")) {
4708
+ const prefix = toolName.slice(0, -1);
4709
+ for (const def2 of availableTools) {
4710
+ if (!def2.name.startsWith(prefix)) continue;
4711
+ if (seen.has(def2.name)) continue;
4712
+ resolved.push(def2);
4713
+ seen.add(def2.name);
4714
+ }
4715
+ continue;
4716
+ }
3368
4717
  if (seen.has(toolName)) continue;
3369
4718
  const def = toolMap.get(toolName);
3370
4719
  if (def) {
@@ -3428,7 +4777,8 @@ Always answer in the same language the user is using. Detailed tool-usage guidan
3428
4777
  maxTokens: 4096
3429
4778
  },
3430
4779
  // Capability bundle lives on the skill; the agent only references it.
3431
- skills: ["data_explorer"],
4780
+ // `data_explorer` = read side, `actions_executor` = write side.
4781
+ skills: ["data_explorer", "actions_executor"],
3432
4782
  active: true,
3433
4783
  visibility: "global",
3434
4784
  guardrails: {
@@ -3508,6 +4858,7 @@ Guidelines:
3508
4858
  7. Never expose internal IDs unless the user explicitly asks for them.
3509
4859
  8. Always answer in the same language the user is using.`,
3510
4860
  tools: [
4861
+ "query_data",
3511
4862
  "list_objects",
3512
4863
  "describe_object",
3513
4864
  "query_records",
@@ -3577,6 +4928,44 @@ Guidelines:
3577
4928
  active: true
3578
4929
  };
3579
4930
 
4931
+ // src/skills/actions-executor-skill.ts
4932
+ var ACTIONS_EXECUTOR_SKILL = {
4933
+ name: "actions_executor",
4934
+ label: "Action Executor",
4935
+ description: "Perform business operations on the user's data \u2014 invoke actions like 'mark as complete', 'start task', 'clone record' through natural language.",
4936
+ instructions: `You can perform business operations by invoking the user's registered actions.
4937
+
4938
+ Capabilities:
4939
+ - Each tool whose name starts with \`action_\` is a business operation declared on an object.
4940
+ - Read the tool description carefully \u2014 it tells you what the action does and what record types it applies to.
4941
+ - 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.
4942
+
4943
+ Guidelines:
4944
+ 1. Confirm intent \u2014 when the user says "complete it" / "start that one", make sure you know *which* record they mean. Ask if ambiguous.
4945
+ 2. Use \`query_data\` to look up records by natural-language description ("the design review task", "tickets assigned to me").
4946
+ 3. After invoking an action, the tool returns \`{ ok, message, result }\`. Summarise success in plain language; surface errors verbatim.
4947
+ 4. Never invent recordIds. If \`query_data\` didn't return one, tell the user instead of guessing.
4948
+ 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.
4949
+ 6. Always answer in the same language the user is using.`,
4950
+ // Dynamically materialised: the runtime registers one tool per Action,
4951
+ // and the skill subscribes to the whole family via the `action_*`
4952
+ // glob (resolved by SkillRegistry.flattenToTools).
4953
+ tools: ["action_*"],
4954
+ triggerPhrases: [
4955
+ "complete",
4956
+ "mark as",
4957
+ "start",
4958
+ "finish",
4959
+ "clone",
4960
+ "duplicate",
4961
+ "do it",
4962
+ "run",
4963
+ "invoke",
4964
+ "execute"
4965
+ ],
4966
+ active: true
4967
+ };
4968
+
3580
4969
  // src/adapters/vercel-adapter.ts
3581
4970
  import { generateText, streamText, generateObject, tool as vercelTool, jsonSchema } from "ai";
3582
4971
  function buildVercelOptions(options) {
@@ -3885,26 +5274,29 @@ var AIServicePlugin = class {
3885
5274
  ctx.logger.info(`[AI] ModelRegistry initialised with ${modelRegistry.size} model(s)`);
3886
5275
  }
3887
5276
  let traceRecorder;
5277
+ let dataEngine;
5278
+ try {
5279
+ const engine = ctx.getService("data");
5280
+ if (engine && typeof engine.insert === "function") {
5281
+ dataEngine = engine;
5282
+ }
5283
+ } catch {
5284
+ }
3888
5285
  if (this.options.traceRecorder === null) {
3889
5286
  ctx.logger.debug("[AI] Tracing disabled (traceRecorder=null)");
3890
5287
  } else if (this.options.traceRecorder) {
3891
5288
  traceRecorder = this.options.traceRecorder;
3892
- } else {
3893
- try {
3894
- const engine = ctx.getService("data");
3895
- if (engine && typeof engine.insert === "function") {
3896
- traceRecorder = new ObjectQLTraceRecorder(engine, { logger: ctx.logger });
3897
- ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
3898
- }
3899
- } catch {
3900
- }
5289
+ } else if (dataEngine) {
5290
+ traceRecorder = new ObjectQLTraceRecorder(dataEngine, { logger: ctx.logger });
5291
+ ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
3901
5292
  }
3902
5293
  const config = {
3903
5294
  adapter,
3904
5295
  logger: ctx.logger,
3905
5296
  conversationService,
3906
5297
  modelRegistry,
3907
- traceRecorder
5298
+ traceRecorder,
5299
+ dataEngine
3908
5300
  };
3909
5301
  this.service = new AIService(config);
3910
5302
  if (hasExisting) {
@@ -3919,7 +5311,8 @@ var AIServicePlugin = class {
3919
5311
  type: "plugin",
3920
5312
  scope: "project",
3921
5313
  namespace: "ai",
3922
- objects: [AiConversationObject, AiMessageObject, AiTraceObject]
5314
+ objects: [AiConversationObject, AiMessageObject, AiTraceObject, AiPendingActionObject],
5315
+ views: [AiTraceView, AiPendingActionView]
3923
5316
  });
3924
5317
  if (this.options.debug) {
3925
5318
  ctx.hook("ai:beforeChat", async (messages) => {
@@ -3958,6 +5351,44 @@ var AIServicePlugin = class {
3958
5351
  dataEngine
3959
5352
  });
3960
5353
  ctx.logger.info("[AI] query_data tool registered");
5354
+ try {
5355
+ let automation;
5356
+ try {
5357
+ automation = ctx.getService("automation");
5358
+ } catch {
5359
+ automation = void 0;
5360
+ }
5361
+ const apiBaseUrl = this.options.apiActionBaseUrl ?? process.env.OS_AI_ACTION_API_BASE_URL;
5362
+ const apiHeaders = this.options.apiActionHeaders;
5363
+ const { registered, skipped } = await registerActionsAsTools(
5364
+ this.service.toolRegistry,
5365
+ {
5366
+ metadata: metadataService,
5367
+ dataEngine,
5368
+ automation,
5369
+ apiBaseUrl,
5370
+ apiHeaders,
5371
+ enableActionApproval: this.options.enableActionApproval ?? false,
5372
+ aiService: this.service
5373
+ }
5374
+ );
5375
+ if (registered.length > 0) {
5376
+ ctx.logger.info(
5377
+ `[AI] ${registered.length} action tool(s) registered: ${registered.join(", ")}`
5378
+ );
5379
+ }
5380
+ if (skipped.length > 0) {
5381
+ ctx.logger.debug(
5382
+ `[AI] Skipped ${skipped.length} action(s) for AI exposure`,
5383
+ { skipped }
5384
+ );
5385
+ }
5386
+ } catch (err) {
5387
+ ctx.logger.warn(
5388
+ "[AI] Failed to register action tools",
5389
+ err instanceof Error ? { error: err.message } : { error: String(err) }
5390
+ );
5391
+ }
3961
5392
  }
3962
5393
  if (metadataService) {
3963
5394
  const { DATA_TOOL_DEFINITIONS: DATA_TOOL_DEFINITIONS2 } = await Promise.resolve().then(() => (init_data_tools(), data_tools_exports));
@@ -4009,6 +5440,19 @@ var AIServicePlugin = class {
4009
5440
  } catch (err) {
4010
5441
  ctx.logger.warn("[AI] Failed to register data_explorer skill", err instanceof Error ? { error: err.message } : { error: String(err) });
4011
5442
  }
5443
+ try {
5444
+ const skillExists = typeof metadataService.exists === "function" ? await withTimeout(metadataService.exists("skill", ACTIONS_EXECUTOR_SKILL.name)) : false;
5445
+ if (skillExists === null) {
5446
+ ctx.logger.warn("[AI] Metadata service timed out checking actions_executor skill, skipping");
5447
+ } else if (!skillExists) {
5448
+ await withTimeout(metadataService.register("skill", ACTIONS_EXECUTOR_SKILL.name, ACTIONS_EXECUTOR_SKILL));
5449
+ ctx.logger.info("[AI] actions_executor skill registered");
5450
+ } else {
5451
+ ctx.logger.debug("[AI] actions_executor skill already exists, skipping auto-registration");
5452
+ }
5453
+ } catch (err) {
5454
+ ctx.logger.warn("[AI] Failed to register actions_executor skill", err instanceof Error ? { error: err.message } : { error: String(err) });
5455
+ }
4012
5456
  }
4013
5457
  }
4014
5458
  } catch {
@@ -4106,6 +5550,9 @@ var AIServicePlugin = class {
4106
5550
  const toolRoutes = buildToolRoutes(this.service, ctx.logger);
4107
5551
  routes.push(...toolRoutes);
4108
5552
  ctx.logger.info(`[AI] Tool routes registered (${toolRoutes.length} routes)`);
5553
+ const pendingRoutes = buildPendingActionRoutes(this.service, ctx.logger);
5554
+ routes.push(...pendingRoutes);
5555
+ ctx.logger.info(`[AI] Pending-action routes registered (${pendingRoutes.length} routes)`);
4109
5556
  if (metadataService) {
4110
5557
  const skillRegistry = new SkillRegistry(metadataService);
4111
5558
  const agentRuntime = new AgentRuntime(metadataService, skillRegistry);
@@ -4468,16 +5915,20 @@ function registerPackageTools(registry, context) {
4468
5915
  registry.register(setActivePackageTool, createSetActivePackageHandler(context));
4469
5916
  }
4470
5917
  export {
5918
+ ACTIONS_EXECUTOR_SKILL,
4471
5919
  AIService,
4472
5920
  AIServicePlugin,
4473
5921
  AgentRuntime,
4474
5922
  AiConversationObject,
4475
5923
  AiMessageObject,
4476
5924
  AiTraceObject,
5925
+ AiTraceView,
4477
5926
  DATA_CHAT_AGENT,
5927
+ DATA_EXPLORER_SKILL,
4478
5928
  DATA_TOOL_DEFINITIONS,
4479
5929
  InMemoryConversationService,
4480
5930
  METADATA_ASSISTANT_AGENT,
5931
+ METADATA_AUTHORING_SKILL,
4481
5932
  METADATA_TOOL_DEFINITIONS,
4482
5933
  MemoryLLMAdapter,
4483
5934
  ModelRegistry,
@@ -4490,6 +5941,9 @@ export {
4490
5941
  SkillRegistry,
4491
5942
  ToolRegistry,
4492
5943
  VercelLLMAdapter,
5944
+ actionSkipReason,
5945
+ actionToToolDefinition,
5946
+ actionToolName,
4493
5947
  addFieldTool,
4494
5948
  buildAIRoutes,
4495
5949
  buildAgentRoutes,
@@ -4509,6 +5963,7 @@ export {
4509
5963
  listObjectsTool,
4510
5964
  listPackagesTool,
4511
5965
  modifyFieldTool,
5966
+ registerActionsAsTools,
4512
5967
  registerDataTools,
4513
5968
  registerMetadataTools,
4514
5969
  registerPackageTools,