@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 +1501 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1147 -117
- package/dist/index.d.ts +1147 -117
- package/dist/index.js +1493 -38
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
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
|
|
848
|
-
const
|
|
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_]+)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
913
|
-
|
|
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
|
|
2864
|
-
|
|
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
|
-
|
|
2965
|
-
'Filter conditions as
|
|
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()).
|
|
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
|
-
).
|
|
2974
|
-
limit: z.number().int().min(1).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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3894
|
-
|
|
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,
|