@objectstack/service-ai 6.1.1 → 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 +904 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -18
- package/dist/index.d.ts +156 -18
- package/dist/index.js +904 -50
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.cjs
CHANGED
|
@@ -1159,7 +1159,12 @@ function pickActionTool(userText, actionTools) {
|
|
|
1159
1159
|
"notify",
|
|
1160
1160
|
"publish",
|
|
1161
1161
|
"unpublish",
|
|
1162
|
-
"mark"
|
|
1162
|
+
"mark",
|
|
1163
|
+
"delete",
|
|
1164
|
+
"remove",
|
|
1165
|
+
"purge",
|
|
1166
|
+
"destroy",
|
|
1167
|
+
"erase"
|
|
1163
1168
|
]);
|
|
1164
1169
|
const hasActionVerb = [...userTokens].some((t) => ACTION_VERBS.has(t));
|
|
1165
1170
|
if (!hasActionVerb) return null;
|
|
@@ -1464,12 +1469,20 @@ function finishPart(result) {
|
|
|
1464
1469
|
}
|
|
1465
1470
|
var _AIService = class _AIService {
|
|
1466
1471
|
constructor(config = {}) {
|
|
1472
|
+
/**
|
|
1473
|
+
* Map of tool-name → dispatcher used to re-run an approved pending
|
|
1474
|
+
* action. Populated by `registerActionsAsTools()` when action
|
|
1475
|
+
* approval is enabled. Kept private because callers should go
|
|
1476
|
+
* through `approvePendingAction()`.
|
|
1477
|
+
*/
|
|
1478
|
+
this.pendingDispatchers = /* @__PURE__ */ new Map();
|
|
1467
1479
|
this.adapter = config.adapter ?? new MemoryLLMAdapter();
|
|
1468
1480
|
this.logger = config.logger ?? (0, import_core.createLogger)({ level: "info", format: "pretty" });
|
|
1469
1481
|
this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
|
|
1470
1482
|
this.conversationService = config.conversationService ?? new InMemoryConversationService();
|
|
1471
1483
|
this.modelRegistry = config.modelRegistry;
|
|
1472
1484
|
this.traceRecorder = config.traceRecorder ?? new NullTraceRecorder();
|
|
1485
|
+
this.dataEngine = config.dataEngine;
|
|
1473
1486
|
this.logger.info(
|
|
1474
1487
|
`[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}, models=${this.modelRegistry?.size ?? 0}`
|
|
1475
1488
|
);
|
|
@@ -1743,11 +1756,144 @@ var _AIService = class _AIService {
|
|
|
1743
1756
|
yield textDeltaPart("stream", result.content);
|
|
1744
1757
|
yield finishPart(result);
|
|
1745
1758
|
}
|
|
1759
|
+
// ── HITL: pending-action queue ─────────────────────────────────
|
|
1760
|
+
/**
|
|
1761
|
+
* Register a dispatcher callback for a tool. Called by
|
|
1762
|
+
* `registerActionsAsTools()` when action approval is enabled so the
|
|
1763
|
+
* approval handler can re-run the exact same code path the LLM
|
|
1764
|
+
* would have triggered.
|
|
1765
|
+
*/
|
|
1766
|
+
registerPendingActionDispatcher(toolName, dispatch) {
|
|
1767
|
+
this.pendingDispatchers.set(toolName, dispatch);
|
|
1768
|
+
}
|
|
1769
|
+
async proposePendingAction(input) {
|
|
1770
|
+
if (!this.dataEngine) {
|
|
1771
|
+
throw new Error("proposePendingAction requires a dataEngine \u2014 wire it via AIServiceConfig.");
|
|
1772
|
+
}
|
|
1773
|
+
const id = `pa_${cryptoRandomId()}`;
|
|
1774
|
+
const row = {
|
|
1775
|
+
id,
|
|
1776
|
+
conversation_id: input.conversationId ?? null,
|
|
1777
|
+
message_id: input.messageId ?? null,
|
|
1778
|
+
object_name: input.objectName,
|
|
1779
|
+
action_name: input.actionName,
|
|
1780
|
+
tool_name: input.toolName,
|
|
1781
|
+
tool_input: JSON.stringify(input.toolInput ?? {}),
|
|
1782
|
+
status: "pending",
|
|
1783
|
+
proposed_by: input.proposedBy ?? "ai_agent",
|
|
1784
|
+
proposed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1785
|
+
};
|
|
1786
|
+
await this.dataEngine.insert("ai_pending_actions", row);
|
|
1787
|
+
this.logger.info(
|
|
1788
|
+
`[AI] pending action proposed: ${id} (${input.toolName} on ${input.objectName})`
|
|
1789
|
+
);
|
|
1790
|
+
return { id };
|
|
1791
|
+
}
|
|
1792
|
+
async approvePendingAction(id, actorId) {
|
|
1793
|
+
if (!this.dataEngine) {
|
|
1794
|
+
throw new Error("approvePendingAction requires a dataEngine.");
|
|
1795
|
+
}
|
|
1796
|
+
const row = await this.loadPendingRow(id);
|
|
1797
|
+
if (row.status !== "pending") {
|
|
1798
|
+
throw new Error(`pending action ${id} is already ${row.status}`);
|
|
1799
|
+
}
|
|
1800
|
+
const dispatch = this.pendingDispatchers.get(row.tool_name);
|
|
1801
|
+
if (!dispatch) {
|
|
1802
|
+
throw new Error(
|
|
1803
|
+
`no dispatcher registered for tool '${row.tool_name}' \u2014 was the AI plugin restarted without re-registering actions?`
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
await this.dataEngine.update(
|
|
1807
|
+
"ai_pending_actions",
|
|
1808
|
+
{
|
|
1809
|
+
id,
|
|
1810
|
+
status: "approved",
|
|
1811
|
+
decided_by: actorId,
|
|
1812
|
+
decided_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1813
|
+
},
|
|
1814
|
+
{ where: { id } }
|
|
1815
|
+
);
|
|
1816
|
+
let parsed = {};
|
|
1817
|
+
try {
|
|
1818
|
+
parsed = row.tool_input ? JSON.parse(row.tool_input) : {};
|
|
1819
|
+
} catch {
|
|
1820
|
+
parsed = {};
|
|
1821
|
+
}
|
|
1822
|
+
try {
|
|
1823
|
+
const out = await dispatch(parsed);
|
|
1824
|
+
await this.dataEngine.update(
|
|
1825
|
+
"ai_pending_actions",
|
|
1826
|
+
{ id, status: "executed", result: JSON.stringify(out ?? null) },
|
|
1827
|
+
{ where: { id } }
|
|
1828
|
+
);
|
|
1829
|
+
this.logger.info(`[AI] pending action ${id} executed by ${actorId}`);
|
|
1830
|
+
return { status: "executed", result: out };
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1833
|
+
await this.dataEngine.update(
|
|
1834
|
+
"ai_pending_actions",
|
|
1835
|
+
{ id, status: "failed", error: msg },
|
|
1836
|
+
{ where: { id } }
|
|
1837
|
+
);
|
|
1838
|
+
this.logger.warn(`[AI] pending action ${id} failed after approval: ${msg}`);
|
|
1839
|
+
return { status: "failed", error: msg };
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
async rejectPendingAction(id, actorId, reason) {
|
|
1843
|
+
if (!this.dataEngine) {
|
|
1844
|
+
throw new Error("rejectPendingAction requires a dataEngine.");
|
|
1845
|
+
}
|
|
1846
|
+
const row = await this.loadPendingRow(id);
|
|
1847
|
+
if (row.status !== "pending") {
|
|
1848
|
+
throw new Error(`pending action ${id} is already ${row.status}`);
|
|
1849
|
+
}
|
|
1850
|
+
await this.dataEngine.update(
|
|
1851
|
+
"ai_pending_actions",
|
|
1852
|
+
{
|
|
1853
|
+
id,
|
|
1854
|
+
status: "rejected",
|
|
1855
|
+
decided_by: actorId,
|
|
1856
|
+
decided_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1857
|
+
rejection_reason: reason ?? null
|
|
1858
|
+
},
|
|
1859
|
+
{ where: { id } }
|
|
1860
|
+
);
|
|
1861
|
+
this.logger.info(`[AI] pending action ${id} rejected by ${actorId}`);
|
|
1862
|
+
}
|
|
1863
|
+
async listPendingActions(filter) {
|
|
1864
|
+
if (!this.dataEngine) return [];
|
|
1865
|
+
const where = {};
|
|
1866
|
+
if (filter?.status) {
|
|
1867
|
+
where.status = Array.isArray(filter.status) ? { in: filter.status } : filter.status;
|
|
1868
|
+
}
|
|
1869
|
+
if (filter?.conversationId) where.conversation_id = filter.conversationId;
|
|
1870
|
+
if (filter?.objectName) where.object_name = filter.objectName;
|
|
1871
|
+
const rows = await this.dataEngine.find("ai_pending_actions", {
|
|
1872
|
+
where,
|
|
1873
|
+
limit: filter?.limit ?? 100,
|
|
1874
|
+
orderBy: [{ field: "proposed_at", order: "desc" }]
|
|
1875
|
+
});
|
|
1876
|
+
return rows;
|
|
1877
|
+
}
|
|
1878
|
+
async loadPendingRow(id) {
|
|
1879
|
+
const rows = await this.dataEngine.find("ai_pending_actions", {
|
|
1880
|
+
where: { id },
|
|
1881
|
+
limit: 1
|
|
1882
|
+
});
|
|
1883
|
+
const row = rows[0];
|
|
1884
|
+
if (!row) throw new Error(`pending action ${id} not found`);
|
|
1885
|
+
return row;
|
|
1886
|
+
}
|
|
1746
1887
|
};
|
|
1747
1888
|
// ── Tool Call Loop ────────────────────────────────────────────
|
|
1748
1889
|
/** Default maximum iterations for the tool call loop. */
|
|
1749
1890
|
_AIService.DEFAULT_MAX_ITERATIONS = 10;
|
|
1750
1891
|
var AIService = _AIService;
|
|
1892
|
+
function cryptoRandomId() {
|
|
1893
|
+
const g = globalThis;
|
|
1894
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
1895
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1896
|
+
}
|
|
1751
1897
|
|
|
1752
1898
|
// src/stream/vercel-stream-encoder.ts
|
|
1753
1899
|
function sse(data) {
|
|
@@ -2632,6 +2778,132 @@ function buildToolRoutes(aiService, logger) {
|
|
|
2632
2778
|
];
|
|
2633
2779
|
}
|
|
2634
2780
|
|
|
2781
|
+
// src/routes/pending-action-routes.ts
|
|
2782
|
+
function buildPendingActionRoutes(aiService, logger) {
|
|
2783
|
+
const supported = typeof aiService.listPendingActions === "function" && typeof aiService.approvePendingAction === "function" && typeof aiService.rejectPendingAction === "function";
|
|
2784
|
+
if (!supported) {
|
|
2785
|
+
logger.warn(
|
|
2786
|
+
"[AI] HITL pending-action methods not implemented on AI service \u2014 routes return 501."
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2789
|
+
const notImpl = () => ({
|
|
2790
|
+
status: 501,
|
|
2791
|
+
body: { error: "Pending-action queue not available (dataEngine not wired)" }
|
|
2792
|
+
});
|
|
2793
|
+
return [
|
|
2794
|
+
// ── List pending actions ───────────────────────────────────────
|
|
2795
|
+
{
|
|
2796
|
+
method: "GET",
|
|
2797
|
+
path: "/api/v1/ai/pending-actions",
|
|
2798
|
+
description: "List pending actions in the HITL approval queue",
|
|
2799
|
+
auth: true,
|
|
2800
|
+
permissions: ["ai:read"],
|
|
2801
|
+
handler: async (req) => {
|
|
2802
|
+
if (!supported) return notImpl();
|
|
2803
|
+
try {
|
|
2804
|
+
const query = req.query ?? {};
|
|
2805
|
+
const status = typeof query.status === "string" ? query.status : void 0;
|
|
2806
|
+
const conversationId = typeof query.conversationId === "string" ? query.conversationId : void 0;
|
|
2807
|
+
const limitRaw = query.limit;
|
|
2808
|
+
const limit = typeof limitRaw === "string" ? Number(limitRaw) : void 0;
|
|
2809
|
+
const rows = await aiService.listPendingActions({
|
|
2810
|
+
status,
|
|
2811
|
+
conversationId,
|
|
2812
|
+
limit: Number.isFinite(limit) ? limit : void 0
|
|
2813
|
+
});
|
|
2814
|
+
return { status: 200, body: { items: rows, total: rows.length } };
|
|
2815
|
+
} catch (err) {
|
|
2816
|
+
logger.error(
|
|
2817
|
+
"[AI Route] /pending-actions list error",
|
|
2818
|
+
err instanceof Error ? err : void 0
|
|
2819
|
+
);
|
|
2820
|
+
return { status: 500, body: { error: "Failed to list pending actions" } };
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
},
|
|
2824
|
+
// ── Get a single pending action ────────────────────────────────
|
|
2825
|
+
{
|
|
2826
|
+
method: "GET",
|
|
2827
|
+
path: "/api/v1/ai/pending-actions/:id",
|
|
2828
|
+
description: "Get a single pending action by id",
|
|
2829
|
+
auth: true,
|
|
2830
|
+
permissions: ["ai:read"],
|
|
2831
|
+
handler: async (req) => {
|
|
2832
|
+
if (!supported) return notImpl();
|
|
2833
|
+
const id = req.params?.id;
|
|
2834
|
+
if (!id) return { status: 400, body: { error: "id is required" } };
|
|
2835
|
+
try {
|
|
2836
|
+
const rows = await aiService.listPendingActions({});
|
|
2837
|
+
const found = rows.find((r) => r.id === id);
|
|
2838
|
+
if (!found) return { status: 404, body: { error: `Pending action ${id} not found` } };
|
|
2839
|
+
return { status: 200, body: found };
|
|
2840
|
+
} catch (err) {
|
|
2841
|
+
logger.error(
|
|
2842
|
+
"[AI Route] /pending-actions/:id error",
|
|
2843
|
+
err instanceof Error ? err : void 0
|
|
2844
|
+
);
|
|
2845
|
+
return { status: 500, body: { error: "Failed to load pending action" } };
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
},
|
|
2849
|
+
// ── Approve & execute ──────────────────────────────────────────
|
|
2850
|
+
{
|
|
2851
|
+
method: "POST",
|
|
2852
|
+
path: "/api/v1/ai/pending-actions/:id/approve",
|
|
2853
|
+
description: "Approve a pending action and execute it immediately",
|
|
2854
|
+
auth: true,
|
|
2855
|
+
permissions: ["ai:approve"],
|
|
2856
|
+
handler: async (req) => {
|
|
2857
|
+
if (!supported) return notImpl();
|
|
2858
|
+
const id = req.params?.id;
|
|
2859
|
+
if (!id) return { status: 400, body: { error: "id is required" } };
|
|
2860
|
+
const actorId = req.user?.id ?? "system";
|
|
2861
|
+
try {
|
|
2862
|
+
const outcome = await aiService.approvePendingAction(id, actorId);
|
|
2863
|
+
const httpStatus = outcome.status === "executed" ? 200 : 500;
|
|
2864
|
+
return { status: httpStatus, body: outcome };
|
|
2865
|
+
} catch (err) {
|
|
2866
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2867
|
+
logger.error("[AI Route] /pending-actions/:id/approve error", err instanceof Error ? err : void 0);
|
|
2868
|
+
if (/not found/i.test(msg)) return { status: 404, body: { error: msg } };
|
|
2869
|
+
if (/already|not pending|no dispatcher/i.test(msg)) {
|
|
2870
|
+
return { status: 409, body: { error: msg } };
|
|
2871
|
+
}
|
|
2872
|
+
return { status: 500, body: { error: msg } };
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
},
|
|
2876
|
+
// ── Reject ─────────────────────────────────────────────────────
|
|
2877
|
+
{
|
|
2878
|
+
method: "POST",
|
|
2879
|
+
path: "/api/v1/ai/pending-actions/:id/reject",
|
|
2880
|
+
description: "Reject a pending action (will not be executed)",
|
|
2881
|
+
auth: true,
|
|
2882
|
+
permissions: ["ai:approve"],
|
|
2883
|
+
handler: async (req) => {
|
|
2884
|
+
if (!supported) return notImpl();
|
|
2885
|
+
const id = req.params?.id;
|
|
2886
|
+
if (!id) return { status: 400, body: { error: "id is required" } };
|
|
2887
|
+
const actorId = req.user?.id ?? "system";
|
|
2888
|
+
const body = req.body ?? {};
|
|
2889
|
+
const reason = typeof body.reason === "string" ? body.reason : void 0;
|
|
2890
|
+
try {
|
|
2891
|
+
await aiService.rejectPendingAction(id, actorId, reason);
|
|
2892
|
+
return { status: 200, body: { status: "rejected", id } };
|
|
2893
|
+
} catch (err) {
|
|
2894
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2895
|
+
logger.error("[AI Route] /pending-actions/:id/reject error", err instanceof Error ? err : void 0);
|
|
2896
|
+
if (/not found/i.test(msg)) return { status: 404, body: { error: msg } };
|
|
2897
|
+
if (/already|not pending/i.test(msg)) {
|
|
2898
|
+
return { status: 409, body: { error: msg } };
|
|
2899
|
+
}
|
|
2900
|
+
return { status: 500, body: { error: msg } };
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
];
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2635
2907
|
// src/conversation/objectql-conversation-service.ts
|
|
2636
2908
|
var import_node_crypto2 = require("crypto");
|
|
2637
2909
|
var CONVERSATIONS_OBJECT = "ai_conversations";
|
|
@@ -3102,6 +3374,149 @@ var AiTraceObject = import_data3.ObjectSchema.create({
|
|
|
3102
3374
|
}
|
|
3103
3375
|
});
|
|
3104
3376
|
|
|
3377
|
+
// src/objects/ai-pending-action.object.ts
|
|
3378
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
3379
|
+
var AiPendingActionObject = import_data4.ObjectSchema.create({
|
|
3380
|
+
name: "ai_pending_actions",
|
|
3381
|
+
label: "AI Pending Action",
|
|
3382
|
+
pluralLabel: "AI Pending Actions",
|
|
3383
|
+
icon: "shield-check",
|
|
3384
|
+
isSystem: true,
|
|
3385
|
+
description: "Queue of AI-proposed action invocations awaiting human approval",
|
|
3386
|
+
fields: {
|
|
3387
|
+
id: import_data4.Field.text({
|
|
3388
|
+
label: "Request ID",
|
|
3389
|
+
required: true,
|
|
3390
|
+
readonly: true
|
|
3391
|
+
}),
|
|
3392
|
+
conversation_id: import_data4.Field.lookup("ai_conversations", {
|
|
3393
|
+
label: "Conversation",
|
|
3394
|
+
required: false,
|
|
3395
|
+
description: "Conversation that produced this proposal, if any"
|
|
3396
|
+
}),
|
|
3397
|
+
message_id: import_data4.Field.lookup("ai_messages", {
|
|
3398
|
+
label: "Message",
|
|
3399
|
+
required: false,
|
|
3400
|
+
description: "Assistant message containing the proposed tool call"
|
|
3401
|
+
}),
|
|
3402
|
+
object_name: import_data4.Field.text({
|
|
3403
|
+
label: "Object",
|
|
3404
|
+
required: true,
|
|
3405
|
+
maxLength: 128,
|
|
3406
|
+
description: 'Target object name (e.g. "task")'
|
|
3407
|
+
}),
|
|
3408
|
+
action_name: import_data4.Field.text({
|
|
3409
|
+
label: "Action",
|
|
3410
|
+
required: true,
|
|
3411
|
+
maxLength: 128,
|
|
3412
|
+
description: 'Declarative action name (e.g. "delete_task")'
|
|
3413
|
+
}),
|
|
3414
|
+
tool_name: import_data4.Field.text({
|
|
3415
|
+
label: "Tool",
|
|
3416
|
+
required: true,
|
|
3417
|
+
maxLength: 128,
|
|
3418
|
+
description: 'AI tool name exposed to the LLM (e.g. "action_delete_task")'
|
|
3419
|
+
}),
|
|
3420
|
+
tool_input: import_data4.Field.textarea({
|
|
3421
|
+
label: "Tool Input",
|
|
3422
|
+
required: true,
|
|
3423
|
+
description: "JSON-serialised tool arguments the LLM passed"
|
|
3424
|
+
}),
|
|
3425
|
+
status: import_data4.Field.select({
|
|
3426
|
+
label: "Status",
|
|
3427
|
+
required: true,
|
|
3428
|
+
defaultValue: "pending",
|
|
3429
|
+
options: [
|
|
3430
|
+
{ label: "Pending Approval", value: "pending" },
|
|
3431
|
+
{ label: "Approved (queued)", value: "approved" },
|
|
3432
|
+
{ label: "Executed", value: "executed" },
|
|
3433
|
+
{ label: "Failed", value: "failed" },
|
|
3434
|
+
{ label: "Rejected", value: "rejected" }
|
|
3435
|
+
]
|
|
3436
|
+
}),
|
|
3437
|
+
result: import_data4.Field.textarea({
|
|
3438
|
+
label: "Execution Result",
|
|
3439
|
+
required: false,
|
|
3440
|
+
description: "JSON-serialised result from the action when executed"
|
|
3441
|
+
}),
|
|
3442
|
+
error: import_data4.Field.textarea({
|
|
3443
|
+
label: "Error",
|
|
3444
|
+
required: false,
|
|
3445
|
+
description: "Error message when status=failed"
|
|
3446
|
+
}),
|
|
3447
|
+
rejection_reason: import_data4.Field.textarea({
|
|
3448
|
+
label: "Rejection Reason",
|
|
3449
|
+
required: false,
|
|
3450
|
+
description: "Why the reviewer rejected (shown back to the LLM)"
|
|
3451
|
+
}),
|
|
3452
|
+
proposed_by: import_data4.Field.text({
|
|
3453
|
+
label: "Proposed By",
|
|
3454
|
+
required: false,
|
|
3455
|
+
maxLength: 128,
|
|
3456
|
+
description: "Principal id of the AI agent that proposed the action"
|
|
3457
|
+
}),
|
|
3458
|
+
decided_by: import_data4.Field.text({
|
|
3459
|
+
label: "Decided By",
|
|
3460
|
+
required: false,
|
|
3461
|
+
maxLength: 128,
|
|
3462
|
+
description: "User id of the human who approved/rejected"
|
|
3463
|
+
}),
|
|
3464
|
+
proposed_at: import_data4.Field.datetime({
|
|
3465
|
+
label: "Proposed At",
|
|
3466
|
+
required: true,
|
|
3467
|
+
defaultValue: "NOW()",
|
|
3468
|
+
readonly: true
|
|
3469
|
+
}),
|
|
3470
|
+
decided_at: import_data4.Field.datetime({
|
|
3471
|
+
label: "Decided At",
|
|
3472
|
+
required: false,
|
|
3473
|
+
description: "When approve/reject happened"
|
|
3474
|
+
})
|
|
3475
|
+
},
|
|
3476
|
+
indexes: [
|
|
3477
|
+
{ fields: ["status"] },
|
|
3478
|
+
{ fields: ["conversation_id"] },
|
|
3479
|
+
{ fields: ["object_name"] },
|
|
3480
|
+
{ fields: ["proposed_at"] }
|
|
3481
|
+
],
|
|
3482
|
+
actions: [
|
|
3483
|
+
{
|
|
3484
|
+
name: "approve_pending_action",
|
|
3485
|
+
label: "Approve",
|
|
3486
|
+
type: "api",
|
|
3487
|
+
target: "/api/v1/ai/pending-actions/{recordId}/approve",
|
|
3488
|
+
method: "POST",
|
|
3489
|
+
locations: ["list_item", "record_header"],
|
|
3490
|
+
variant: "primary",
|
|
3491
|
+
confirmText: "Approve and execute this action now?",
|
|
3492
|
+
successMessage: "Action approved and executed.",
|
|
3493
|
+
// The approval click is the operator's authorisation gesture —
|
|
3494
|
+
// the LLM must not be allowed to bypass HITL by approving itself.
|
|
3495
|
+
aiExposed: false
|
|
3496
|
+
},
|
|
3497
|
+
{
|
|
3498
|
+
name: "reject_pending_action",
|
|
3499
|
+
label: "Reject",
|
|
3500
|
+
type: "api",
|
|
3501
|
+
target: "/api/v1/ai/pending-actions/{recordId}/reject",
|
|
3502
|
+
method: "POST",
|
|
3503
|
+
locations: ["list_item", "record_header"],
|
|
3504
|
+
variant: "danger",
|
|
3505
|
+
confirmText: "Reject this pending action? It will not be executed.",
|
|
3506
|
+
successMessage: "Action rejected.",
|
|
3507
|
+
aiExposed: false
|
|
3508
|
+
}
|
|
3509
|
+
],
|
|
3510
|
+
enable: {
|
|
3511
|
+
trackHistory: false,
|
|
3512
|
+
searchable: false,
|
|
3513
|
+
apiEnabled: true,
|
|
3514
|
+
apiMethods: ["get", "list"],
|
|
3515
|
+
trash: false,
|
|
3516
|
+
mru: false
|
|
3517
|
+
}
|
|
3518
|
+
});
|
|
3519
|
+
|
|
3105
3520
|
// src/views/ai-trace.view.ts
|
|
3106
3521
|
var import_spec = require("@objectstack/spec");
|
|
3107
3522
|
var AiTraceView = (0, import_spec.defineView)({
|
|
@@ -3159,6 +3574,238 @@ var AiTraceView = (0, import_spec.defineView)({
|
|
|
3159
3574
|
}
|
|
3160
3575
|
});
|
|
3161
3576
|
|
|
3577
|
+
// src/views/ai-pending-action.view.ts
|
|
3578
|
+
var import_spec2 = require("@objectstack/spec");
|
|
3579
|
+
var AiPendingActionView = (0, import_spec2.defineView)({
|
|
3580
|
+
list: {
|
|
3581
|
+
type: "grid",
|
|
3582
|
+
data: { provider: "object", object: "ai_pending_actions" },
|
|
3583
|
+
columns: [
|
|
3584
|
+
{ field: "proposed_at", label: "Proposed", type: "datetime-relative", width: 140 },
|
|
3585
|
+
{ field: "status", width: 130 },
|
|
3586
|
+
{ field: "object_name", label: "Object", width: 140 },
|
|
3587
|
+
{ field: "action_name", label: "Action", width: 180 },
|
|
3588
|
+
{ field: "proposed_by", label: "Proposed by", width: 160 },
|
|
3589
|
+
{ field: "decided_by", label: "Decided by", width: 160 },
|
|
3590
|
+
{ field: "decided_at", label: "Decided", type: "datetime-relative", width: 140 }
|
|
3591
|
+
],
|
|
3592
|
+
sort: [{ field: "proposed_at", order: "desc" }],
|
|
3593
|
+
pagination: { pageSize: 50 },
|
|
3594
|
+
searchableFields: ["action_name", "object_name", "tool_name", "proposed_by"],
|
|
3595
|
+
filterableFields: ["status", "object_name", "action_name"],
|
|
3596
|
+
rowActions: ["approve_pending_action", "reject_pending_action"],
|
|
3597
|
+
// Click a row → open the detail drawer instead of navigating to a page.
|
|
3598
|
+
navigation: { mode: "drawer", view: "detail", width: "640px" },
|
|
3599
|
+
rowColor: {
|
|
3600
|
+
field: "status",
|
|
3601
|
+
mapping: {
|
|
3602
|
+
pending: "amber",
|
|
3603
|
+
approved: "blue",
|
|
3604
|
+
executed: "green",
|
|
3605
|
+
failed: "red",
|
|
3606
|
+
rejected: "gray"
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
},
|
|
3610
|
+
form: {
|
|
3611
|
+
type: "drawer",
|
|
3612
|
+
data: { provider: "object", object: "ai_pending_actions" },
|
|
3613
|
+
sections: [
|
|
3614
|
+
{
|
|
3615
|
+
label: "Proposal",
|
|
3616
|
+
columns: 2,
|
|
3617
|
+
fields: [
|
|
3618
|
+
{ field: "status", readonly: true },
|
|
3619
|
+
{ field: "proposed_at", readonly: true, widget: "datetime-relative" },
|
|
3620
|
+
{ field: "object_name", label: "Target object", readonly: true },
|
|
3621
|
+
{ field: "action_name", label: "Action", readonly: true },
|
|
3622
|
+
{ field: "tool_name", label: "Tool exposed to LLM", readonly: true, colSpan: 2 },
|
|
3623
|
+
{ field: "proposed_by", label: "Proposed by (AI agent)", readonly: true, colSpan: 2 }
|
|
3624
|
+
]
|
|
3625
|
+
},
|
|
3626
|
+
{
|
|
3627
|
+
label: "Tool input",
|
|
3628
|
+
collapsible: true,
|
|
3629
|
+
columns: 1,
|
|
3630
|
+
fields: [
|
|
3631
|
+
{
|
|
3632
|
+
field: "tool_input",
|
|
3633
|
+
label: "Arguments the LLM sent",
|
|
3634
|
+
readonly: true,
|
|
3635
|
+
widget: "json",
|
|
3636
|
+
colSpan: 1,
|
|
3637
|
+
helpText: "Pretty-printed JSON. Review carefully before approving \u2014 this is the exact payload that will be re-played against the handler."
|
|
3638
|
+
}
|
|
3639
|
+
]
|
|
3640
|
+
},
|
|
3641
|
+
{
|
|
3642
|
+
label: "Conversation context",
|
|
3643
|
+
collapsible: true,
|
|
3644
|
+
collapsed: true,
|
|
3645
|
+
columns: 2,
|
|
3646
|
+
fields: [
|
|
3647
|
+
// Both are lookups — Studio renders them as links to the related
|
|
3648
|
+
// ai_conversations / ai_messages record so operators can jump to
|
|
3649
|
+
// the full transcript for context.
|
|
3650
|
+
{ field: "conversation_id", label: "Conversation", readonly: true },
|
|
3651
|
+
{ field: "message_id", label: "Assistant message", readonly: true }
|
|
3652
|
+
]
|
|
3653
|
+
},
|
|
3654
|
+
{
|
|
3655
|
+
label: "Decision",
|
|
3656
|
+
collapsible: true,
|
|
3657
|
+
// Only meaningful once the row has been actioned; left collapsed
|
|
3658
|
+
// by default for pending rows so the eye lands on the proposal.
|
|
3659
|
+
collapsed: true,
|
|
3660
|
+
columns: 2,
|
|
3661
|
+
fields: [
|
|
3662
|
+
{ field: "decided_by", label: "Decided by", readonly: true },
|
|
3663
|
+
{ field: "decided_at", label: "Decided", readonly: true, widget: "datetime-relative" },
|
|
3664
|
+
{
|
|
3665
|
+
field: "rejection_reason",
|
|
3666
|
+
label: "Rejection reason",
|
|
3667
|
+
readonly: true,
|
|
3668
|
+
colSpan: 2,
|
|
3669
|
+
visibleOn: 'record.status == "rejected"'
|
|
3670
|
+
},
|
|
3671
|
+
{
|
|
3672
|
+
field: "result",
|
|
3673
|
+
label: "Execution result",
|
|
3674
|
+
readonly: true,
|
|
3675
|
+
widget: "json",
|
|
3676
|
+
colSpan: 2,
|
|
3677
|
+
visibleOn: 'record.status == "executed"'
|
|
3678
|
+
},
|
|
3679
|
+
{
|
|
3680
|
+
field: "error",
|
|
3681
|
+
label: "Error",
|
|
3682
|
+
readonly: true,
|
|
3683
|
+
colSpan: 2,
|
|
3684
|
+
visibleOn: 'record.status == "failed"'
|
|
3685
|
+
}
|
|
3686
|
+
]
|
|
3687
|
+
}
|
|
3688
|
+
]
|
|
3689
|
+
},
|
|
3690
|
+
formViews: {
|
|
3691
|
+
detail: {
|
|
3692
|
+
type: "drawer",
|
|
3693
|
+
data: { provider: "object", object: "ai_pending_actions" },
|
|
3694
|
+
// Mirror of the default form. Named separately so the list's
|
|
3695
|
+
// `navigation.view: 'detail'` resolves explicitly — Studio falls back
|
|
3696
|
+
// to `form` if a named view isn't registered, but being explicit
|
|
3697
|
+
// makes the wiring legible to readers of the metadata.
|
|
3698
|
+
sections: [
|
|
3699
|
+
{
|
|
3700
|
+
label: "Proposal",
|
|
3701
|
+
columns: 2,
|
|
3702
|
+
fields: [
|
|
3703
|
+
{ field: "status", readonly: true },
|
|
3704
|
+
{ field: "proposed_at", readonly: true, widget: "datetime-relative" },
|
|
3705
|
+
{ field: "object_name", label: "Target object", readonly: true },
|
|
3706
|
+
{ field: "action_name", label: "Action", readonly: true },
|
|
3707
|
+
{ field: "tool_name", label: "Tool exposed to LLM", readonly: true, colSpan: 2 },
|
|
3708
|
+
{ field: "proposed_by", label: "Proposed by (AI agent)", readonly: true, colSpan: 2 }
|
|
3709
|
+
]
|
|
3710
|
+
},
|
|
3711
|
+
{
|
|
3712
|
+
label: "Tool input",
|
|
3713
|
+
collapsible: true,
|
|
3714
|
+
columns: 1,
|
|
3715
|
+
fields: [
|
|
3716
|
+
{ field: "tool_input", label: "Arguments the LLM sent", readonly: true, widget: "json" }
|
|
3717
|
+
]
|
|
3718
|
+
},
|
|
3719
|
+
{
|
|
3720
|
+
label: "Conversation context",
|
|
3721
|
+
collapsible: true,
|
|
3722
|
+
collapsed: true,
|
|
3723
|
+
columns: 2,
|
|
3724
|
+
fields: [
|
|
3725
|
+
{ field: "conversation_id", label: "Conversation", readonly: true },
|
|
3726
|
+
{ field: "message_id", label: "Assistant message", readonly: true }
|
|
3727
|
+
]
|
|
3728
|
+
},
|
|
3729
|
+
{
|
|
3730
|
+
label: "Decision",
|
|
3731
|
+
collapsible: true,
|
|
3732
|
+
collapsed: true,
|
|
3733
|
+
columns: 2,
|
|
3734
|
+
fields: [
|
|
3735
|
+
{ field: "decided_by", label: "Decided by", readonly: true },
|
|
3736
|
+
{ field: "decided_at", label: "Decided", readonly: true, widget: "datetime-relative" },
|
|
3737
|
+
{ field: "rejection_reason", label: "Rejection reason", readonly: true, colSpan: 2, visibleOn: 'record.status == "rejected"' },
|
|
3738
|
+
{ field: "result", label: "Execution result", readonly: true, widget: "json", colSpan: 2, visibleOn: 'record.status == "executed"' },
|
|
3739
|
+
{ field: "error", label: "Error", readonly: true, colSpan: 2, visibleOn: 'record.status == "failed"' }
|
|
3740
|
+
]
|
|
3741
|
+
}
|
|
3742
|
+
]
|
|
3743
|
+
}
|
|
3744
|
+
},
|
|
3745
|
+
listViews: {
|
|
3746
|
+
pending: {
|
|
3747
|
+
label: "Pending",
|
|
3748
|
+
type: "grid",
|
|
3749
|
+
data: { provider: "object", object: "ai_pending_actions" },
|
|
3750
|
+
columns: [
|
|
3751
|
+
{ field: "proposed_at", label: "Proposed", type: "datetime-relative", width: 140 },
|
|
3752
|
+
{ field: "object_name", label: "Object", width: 140 },
|
|
3753
|
+
{ field: "action_name", label: "Action", width: 180 },
|
|
3754
|
+
{ field: "proposed_by", label: "Proposed by", width: 160 },
|
|
3755
|
+
{ field: "tool_name", label: "Tool", width: 200 }
|
|
3756
|
+
],
|
|
3757
|
+
filter: [{ field: "status", operator: "=", value: "pending" }],
|
|
3758
|
+
sort: [{ field: "proposed_at", order: "desc" }],
|
|
3759
|
+
rowActions: ["approve_pending_action", "reject_pending_action"],
|
|
3760
|
+
navigation: { mode: "drawer", view: "detail", width: "640px" }
|
|
3761
|
+
},
|
|
3762
|
+
executed: {
|
|
3763
|
+
label: "Executed",
|
|
3764
|
+
type: "grid",
|
|
3765
|
+
data: { provider: "object", object: "ai_pending_actions" },
|
|
3766
|
+
columns: [
|
|
3767
|
+
{ field: "decided_at", label: "Approved", type: "datetime-relative", width: 140 },
|
|
3768
|
+
{ field: "object_name", label: "Object", width: 140 },
|
|
3769
|
+
{ field: "action_name", label: "Action", width: 180 },
|
|
3770
|
+
{ field: "decided_by", label: "Approved by", width: 160 },
|
|
3771
|
+
{ field: "proposed_by", label: "Proposed by", width: 160 }
|
|
3772
|
+
],
|
|
3773
|
+
filter: [{ field: "status", operator: "=", value: "executed" }],
|
|
3774
|
+
sort: [{ field: "decided_at", order: "desc" }],
|
|
3775
|
+
navigation: { mode: "drawer", view: "detail", width: "640px" }
|
|
3776
|
+
},
|
|
3777
|
+
rejected: {
|
|
3778
|
+
label: "Rejected",
|
|
3779
|
+
type: "grid",
|
|
3780
|
+
data: { provider: "object", object: "ai_pending_actions" },
|
|
3781
|
+
columns: [
|
|
3782
|
+
{ field: "decided_at", label: "Rejected", type: "datetime-relative", width: 140 },
|
|
3783
|
+
{ field: "object_name", label: "Object", width: 140 },
|
|
3784
|
+
{ field: "action_name", label: "Action", width: 180 },
|
|
3785
|
+
{ field: "decided_by", label: "Rejected by", width: 160 },
|
|
3786
|
+
{ field: "rejection_reason", label: "Reason", wrap: true }
|
|
3787
|
+
],
|
|
3788
|
+
filter: [{ field: "status", operator: "=", value: "rejected" }],
|
|
3789
|
+
sort: [{ field: "decided_at", order: "desc" }],
|
|
3790
|
+
navigation: { mode: "drawer", view: "detail", width: "640px" }
|
|
3791
|
+
},
|
|
3792
|
+
failed: {
|
|
3793
|
+
label: "Failed",
|
|
3794
|
+
type: "grid",
|
|
3795
|
+
data: { provider: "object", object: "ai_pending_actions" },
|
|
3796
|
+
columns: [
|
|
3797
|
+
{ field: "decided_at", label: "When", type: "datetime-relative", width: 140 },
|
|
3798
|
+
{ field: "object_name", label: "Object", width: 140 },
|
|
3799
|
+
{ field: "action_name", label: "Action", width: 180 },
|
|
3800
|
+
{ field: "error", wrap: true }
|
|
3801
|
+
],
|
|
3802
|
+
filter: [{ field: "status", operator: "=", value: "failed" }],
|
|
3803
|
+
sort: [{ field: "decided_at", order: "desc" }],
|
|
3804
|
+
navigation: { mode: "drawer", view: "detail", width: "640px" }
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
});
|
|
3808
|
+
|
|
3162
3809
|
// src/plugin.ts
|
|
3163
3810
|
init_data_tools();
|
|
3164
3811
|
init_metadata_tools();
|
|
@@ -3315,17 +3962,17 @@ function describeField(field) {
|
|
|
3315
3962
|
// src/tools/query-data.tool.ts
|
|
3316
3963
|
var QueryPlanSchema = import_zod.z.object({
|
|
3317
3964
|
objectName: import_zod.z.string().min(1).describe('The snake_case object name to query (e.g. "task", "account").'),
|
|
3318
|
-
|
|
3319
|
-
'Filter conditions as
|
|
3965
|
+
whereJson: import_zod.z.string().nullable().describe(
|
|
3966
|
+
'Filter conditions encoded as a JSON object string. Examples: `{"status":"completed"}`, `{"subject":{"$contains":"Build"}}`, `{"amount":{"$gt":100}}`. Pass null to match all records.'
|
|
3320
3967
|
),
|
|
3321
|
-
fields: import_zod.z.array(import_zod.z.string()).
|
|
3968
|
+
fields: import_zod.z.array(import_zod.z.string()).nullable().describe("Field names to return. Pass null to return all fields."),
|
|
3322
3969
|
orderBy: import_zod.z.array(
|
|
3323
3970
|
import_zod.z.object({
|
|
3324
3971
|
field: import_zod.z.string(),
|
|
3325
3972
|
order: import_zod.z.enum(["asc", "desc"])
|
|
3326
3973
|
})
|
|
3327
|
-
).
|
|
3328
|
-
limit: import_zod.z.number().int().min(1).max(200).
|
|
3974
|
+
).nullable().describe("Sort order. First entry is primary sort key. Pass null for no sort."),
|
|
3975
|
+
limit: import_zod.z.number().int().min(1).max(200).nullable().describe("Maximum number of records (default 20, max 200). Pass null for default.")
|
|
3329
3976
|
});
|
|
3330
3977
|
var QUERY_DATA_TOOL = {
|
|
3331
3978
|
name: "query_data",
|
|
@@ -3336,10 +3983,6 @@ var QUERY_DATA_TOOL = {
|
|
|
3336
3983
|
request: {
|
|
3337
3984
|
type: "string",
|
|
3338
3985
|
description: "The natural-language question to answer (paraphrase the user's request if needed for clarity)."
|
|
3339
|
-
},
|
|
3340
|
-
model: {
|
|
3341
|
-
type: "string",
|
|
3342
|
-
description: "Optional model id to use for query planning. Defaults to the AI service's default model."
|
|
3343
3986
|
}
|
|
3344
3987
|
},
|
|
3345
3988
|
required: ["request"],
|
|
@@ -3350,7 +3993,7 @@ function createQueryDataHandler(ctx) {
|
|
|
3350
3993
|
const retriever = new SchemaRetriever(ctx.metadata);
|
|
3351
3994
|
const maxLimit = ctx.maxLimit ?? 100;
|
|
3352
3995
|
return async (args) => {
|
|
3353
|
-
const { request
|
|
3996
|
+
const { request } = args;
|
|
3354
3997
|
if (!request || typeof request !== "string") {
|
|
3355
3998
|
return JSON.stringify({ error: "query_data: `request` is required" });
|
|
3356
3999
|
}
|
|
@@ -3369,14 +4012,13 @@ function createQueryDataHandler(ctx) {
|
|
|
3369
4012
|
const planMessages = [
|
|
3370
4013
|
{
|
|
3371
4014
|
role: "system",
|
|
3372
|
-
content:
|
|
4015
|
+
content: 'You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\nFilter operator hints:\n \u2022 For partial string matches (e.g. "task named Foo", "find X"), use case-insensitive substring matching with `$contains`: `{"subject": {"$contains": "Foo"}}`. Do NOT use equality unless the user clearly supplied the exact full value.\n \u2022 For numeric/date ranges use `$gt` / `$gte` / `$lt` / `$lte`.\n \u2022 For "is one of" use `$in: [...]`.\n \u2022 For exact equality just write the value: `{"status": "completed"}`.\n\n' + snippet
|
|
3373
4016
|
},
|
|
3374
4017
|
{ role: "user", content: request }
|
|
3375
4018
|
];
|
|
3376
4019
|
let plan;
|
|
3377
4020
|
try {
|
|
3378
4021
|
const generated = await ctx.ai.generateObject(planMessages, QueryPlanSchema, {
|
|
3379
|
-
model,
|
|
3380
4022
|
schemaName: "ObjectQLQueryPlan",
|
|
3381
4023
|
schemaDescription: "A single ObjectQL find() query to answer the user request."
|
|
3382
4024
|
});
|
|
@@ -3393,15 +4035,34 @@ function createQueryDataHandler(ctx) {
|
|
|
3393
4035
|
});
|
|
3394
4036
|
}
|
|
3395
4037
|
const limit = Math.min(plan.limit ?? 20, maxLimit);
|
|
4038
|
+
let where;
|
|
4039
|
+
if (plan.whereJson) {
|
|
4040
|
+
try {
|
|
4041
|
+
const parsed = JSON.parse(plan.whereJson);
|
|
4042
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
4043
|
+
where = parsed;
|
|
4044
|
+
} else {
|
|
4045
|
+
return JSON.stringify({
|
|
4046
|
+
plan,
|
|
4047
|
+
error: `whereJson must encode a JSON object, got: ${plan.whereJson}`
|
|
4048
|
+
});
|
|
4049
|
+
}
|
|
4050
|
+
} catch (err) {
|
|
4051
|
+
return JSON.stringify({
|
|
4052
|
+
plan,
|
|
4053
|
+
error: `whereJson is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
4054
|
+
});
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
3396
4057
|
try {
|
|
3397
4058
|
const records = await ctx.dataEngine.find(plan.objectName, {
|
|
3398
|
-
where
|
|
3399
|
-
fields: plan.fields,
|
|
3400
|
-
orderBy: plan.orderBy,
|
|
4059
|
+
where,
|
|
4060
|
+
fields: plan.fields ?? void 0,
|
|
4061
|
+
orderBy: plan.orderBy ?? void 0,
|
|
3401
4062
|
limit
|
|
3402
4063
|
});
|
|
3403
4064
|
return JSON.stringify({
|
|
3404
|
-
plan,
|
|
4065
|
+
plan: { ...plan, where },
|
|
3405
4066
|
count: records.length,
|
|
3406
4067
|
records
|
|
3407
4068
|
});
|
|
@@ -3418,15 +4079,43 @@ function registerQueryDataTool(registry, context) {
|
|
|
3418
4079
|
}
|
|
3419
4080
|
|
|
3420
4081
|
// src/tools/action-tools.ts
|
|
3421
|
-
function
|
|
4082
|
+
function actionRequiresApproval(action) {
|
|
4083
|
+
return Boolean(
|
|
4084
|
+
action.confirmText || action.mode === "delete" || action.variant === "danger"
|
|
4085
|
+
);
|
|
4086
|
+
}
|
|
4087
|
+
function actionSkipReason(action, ctx) {
|
|
3422
4088
|
if (action.aiExposed === false) {
|
|
3423
4089
|
return "opted-out via aiExposed:false";
|
|
3424
4090
|
}
|
|
3425
|
-
if (action.type
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
if (action.
|
|
3429
|
-
|
|
4091
|
+
if (action.type === "url" || action.type === "modal" || action.type === "form") {
|
|
4092
|
+
return `type='${action.type}' is UI-only`;
|
|
4093
|
+
}
|
|
4094
|
+
if (action.type !== "script" && action.type !== "api" && action.type !== "flow") {
|
|
4095
|
+
return `type='${action.type}' not supported`;
|
|
4096
|
+
}
|
|
4097
|
+
if (action.type === "script" && !action.target && !action.body) {
|
|
4098
|
+
return "no target or body";
|
|
4099
|
+
}
|
|
4100
|
+
if ((action.type === "api" || action.type === "flow") && !action.target) {
|
|
4101
|
+
return `type='${action.type}' requires a target`;
|
|
4102
|
+
}
|
|
4103
|
+
if (ctx) {
|
|
4104
|
+
if (action.type === "flow" && !ctx.automation) {
|
|
4105
|
+
return "no automation service available";
|
|
4106
|
+
}
|
|
4107
|
+
if (action.type === "api" && !ctx.apiClient && !ctx.apiBaseUrl) {
|
|
4108
|
+
return "no apiClient or apiBaseUrl configured";
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
if (actionRequiresApproval(action)) {
|
|
4112
|
+
const approvalReady = ctx?.enableActionApproval === true && Boolean(ctx?.aiService?.proposePendingAction);
|
|
4113
|
+
if (!approvalReady) {
|
|
4114
|
+
if (action.confirmText) return "requires confirmation (confirmText set)";
|
|
4115
|
+
if (action.mode === "delete") return "mode='delete' \u2014 destructive";
|
|
4116
|
+
if (action.variant === "danger") return "variant='danger' \u2014 destructive";
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
3430
4119
|
return null;
|
|
3431
4120
|
}
|
|
3432
4121
|
function fieldTypeToJsonType(t) {
|
|
@@ -3525,7 +4214,8 @@ function describeAction(action, ownerObject) {
|
|
|
3525
4214
|
return parts.join(" ");
|
|
3526
4215
|
}
|
|
3527
4216
|
function actionToToolDefinition(action, ownerObject, allObjects, toolPrefix = "action_") {
|
|
3528
|
-
if (
|
|
4217
|
+
if (action.aiExposed === false) return null;
|
|
4218
|
+
if (action.type === "url" || action.type === "modal" || action.type === "form") return null;
|
|
3529
4219
|
return {
|
|
3530
4220
|
name: actionToolName(action, toolPrefix),
|
|
3531
4221
|
description: describeAction(action, ownerObject),
|
|
@@ -3537,12 +4227,19 @@ function buildHandlerEngineAdapter(engine) {
|
|
|
3537
4227
|
update: (object, id, data) => engine.update(object, { ...data, id }, { where: { id } }),
|
|
3538
4228
|
insert: (object, data) => engine.insert(object, data),
|
|
3539
4229
|
find: (object, where) => engine.find(object, { where }),
|
|
3540
|
-
delete: (object, ids) =>
|
|
4230
|
+
delete: async (object, ids) => {
|
|
4231
|
+
if (!Array.isArray(ids) || ids.length === 0) return 0;
|
|
4232
|
+
let count = 0;
|
|
4233
|
+
for (const id of ids) {
|
|
4234
|
+
await engine.delete(object, { where: { id } });
|
|
4235
|
+
count++;
|
|
4236
|
+
}
|
|
4237
|
+
return count;
|
|
4238
|
+
}
|
|
3541
4239
|
};
|
|
3542
4240
|
}
|
|
3543
4241
|
function createActionToolHandler(action, ctx) {
|
|
3544
4242
|
const principal = ctx.principal ?? { id: "ai_agent", name: "AI Assistant" };
|
|
3545
|
-
const engineAdapter = buildHandlerEngineAdapter(ctx.dataEngine);
|
|
3546
4243
|
const requiresRecord = Array.isArray(action.locations) && action.locations.some(
|
|
3547
4244
|
(l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related"
|
|
3548
4245
|
);
|
|
@@ -3558,8 +4255,8 @@ function createActionToolHandler(action, ctx) {
|
|
|
3558
4255
|
result.error = "Action has no objectName; cannot dispatch.";
|
|
3559
4256
|
return JSON.stringify(result);
|
|
3560
4257
|
}
|
|
3561
|
-
if (!target) {
|
|
3562
|
-
result.error = "Action has no target
|
|
4258
|
+
if (!target && action.type !== "script") {
|
|
4259
|
+
result.error = "Action has no target.";
|
|
3563
4260
|
return JSON.stringify(result);
|
|
3564
4261
|
}
|
|
3565
4262
|
const recordId = typeof args.recordId === "string" && args.recordId.length > 0 ? args.recordId : void 0;
|
|
@@ -3586,14 +4283,40 @@ function createActionToolHandler(action, ctx) {
|
|
|
3586
4283
|
}
|
|
3587
4284
|
}
|
|
3588
4285
|
const { recordId: _omit, ...userParams } = args;
|
|
4286
|
+
if (ctx.enableActionApproval && actionRequiresApproval(action) && ctx.aiService?.proposePendingAction) {
|
|
4287
|
+
try {
|
|
4288
|
+
const toolName = `${ctx.toolPrefix ?? "action_"}${action.name}`;
|
|
4289
|
+
const { id } = await ctx.aiService.proposePendingAction({
|
|
4290
|
+
objectName,
|
|
4291
|
+
actionName: action.name,
|
|
4292
|
+
toolName,
|
|
4293
|
+
toolInput: args,
|
|
4294
|
+
proposedBy: principal.id
|
|
4295
|
+
});
|
|
4296
|
+
const pending = {
|
|
4297
|
+
ok: true,
|
|
4298
|
+
action: action.name,
|
|
4299
|
+
objectName,
|
|
4300
|
+
recordId,
|
|
4301
|
+
status: "pending_approval",
|
|
4302
|
+
pendingActionId: id,
|
|
4303
|
+
message: `Action '${action.name}' is destructive and requires human approval. Proposal queued as ${id}. An operator must approve via Studio's pending-actions inbox before it runs. Do NOT call this tool again for the same intent \u2014 wait for the operator.`
|
|
4304
|
+
};
|
|
4305
|
+
return JSON.stringify(pending);
|
|
4306
|
+
} catch (err) {
|
|
4307
|
+
result.error = `Failed to enqueue approval: ${err instanceof Error ? err.message : String(err)}`;
|
|
4308
|
+
return JSON.stringify(result);
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
3589
4311
|
try {
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
}
|
|
3596
|
-
|
|
4312
|
+
let out;
|
|
4313
|
+
if (action.type === "api") {
|
|
4314
|
+
out = await dispatchApiAction(action, ctx, userParams, record, recordId);
|
|
4315
|
+
} else if (action.type === "flow") {
|
|
4316
|
+
out = await dispatchFlowAction(action, ctx, userParams, record, principal);
|
|
4317
|
+
} else {
|
|
4318
|
+
out = await dispatchScriptAction(action, ctx, userParams, record, principal);
|
|
4319
|
+
}
|
|
3597
4320
|
result.ok = true;
|
|
3598
4321
|
result.result = out ?? null;
|
|
3599
4322
|
const successMsg = typeof action.successMessage === "string" ? action.successMessage : void 0;
|
|
@@ -3605,6 +4328,87 @@ function createActionToolHandler(action, ctx) {
|
|
|
3605
4328
|
}
|
|
3606
4329
|
};
|
|
3607
4330
|
}
|
|
4331
|
+
async function dispatchScriptAction(action, ctx, params, record, principal) {
|
|
4332
|
+
const engineAdapter = buildHandlerEngineAdapter(ctx.dataEngine);
|
|
4333
|
+
const handlerCtx = { record, user: principal, engine: engineAdapter, params };
|
|
4334
|
+
return await ctx.dataEngine.executeAction?.(action.objectName, action.target, handlerCtx);
|
|
4335
|
+
}
|
|
4336
|
+
function buildApiRequestBody(action, args, record, recordId) {
|
|
4337
|
+
const shape = action.bodyShape;
|
|
4338
|
+
const wrapKey = shape && typeof shape === "object" && "wrap" in shape && typeof shape.wrap === "string" ? shape.wrap : void 0;
|
|
4339
|
+
const body = wrapKey ? { [wrapKey]: { ...args } } : { ...args };
|
|
4340
|
+
if (action.recordIdParam) {
|
|
4341
|
+
const idField = action.recordIdField ?? "id";
|
|
4342
|
+
const idValue = record ? record[idField] : recordId;
|
|
4343
|
+
if (idValue !== void 0) body[action.recordIdParam] = idValue;
|
|
4344
|
+
}
|
|
4345
|
+
if (action.bodyExtra && typeof action.bodyExtra === "object") {
|
|
4346
|
+
Object.assign(body, action.bodyExtra);
|
|
4347
|
+
}
|
|
4348
|
+
return body;
|
|
4349
|
+
}
|
|
4350
|
+
async function dispatchApiAction(action, ctx, params, record, recordId) {
|
|
4351
|
+
const client = ctx.apiClient ?? (ctx.apiBaseUrl ? createFetchApiClient({ baseUrl: ctx.apiBaseUrl, headers: ctx.apiHeaders }) : void 0);
|
|
4352
|
+
if (!client) {
|
|
4353
|
+
throw new Error('No apiClient configured for type:"api" action dispatch.');
|
|
4354
|
+
}
|
|
4355
|
+
const method = action.method ?? "POST";
|
|
4356
|
+
const body = buildApiRequestBody(action, params, record, recordId);
|
|
4357
|
+
return await client.request({
|
|
4358
|
+
url: action.target,
|
|
4359
|
+
method,
|
|
4360
|
+
body: method === "GET" || method === "DELETE" ? void 0 : body,
|
|
4361
|
+
headers: ctx.apiHeaders
|
|
4362
|
+
});
|
|
4363
|
+
}
|
|
4364
|
+
async function dispatchFlowAction(action, ctx, params, record, principal) {
|
|
4365
|
+
if (!ctx.automation) {
|
|
4366
|
+
throw new Error('No automation service available for type:"flow" action dispatch.');
|
|
4367
|
+
}
|
|
4368
|
+
const result = await ctx.automation.execute(action.target, {
|
|
4369
|
+
triggerData: { record, params, user: principal, action: action.name }
|
|
4370
|
+
});
|
|
4371
|
+
if (result && typeof result === "object" && "success" in result && result.success === false) {
|
|
4372
|
+
throw new Error(
|
|
4373
|
+
`Flow '${action.target}' failed: ${result.error ?? "unknown error"}`
|
|
4374
|
+
);
|
|
4375
|
+
}
|
|
4376
|
+
return result;
|
|
4377
|
+
}
|
|
4378
|
+
function createFetchApiClient(options) {
|
|
4379
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4380
|
+
if (!fetchImpl) {
|
|
4381
|
+
throw new Error("createFetchApiClient: no global fetch available; pass options.fetch.");
|
|
4382
|
+
}
|
|
4383
|
+
return {
|
|
4384
|
+
async request({ url, method, body, headers }) {
|
|
4385
|
+
const absolute = /^https?:\/\//.test(url) ? url : `${(options.baseUrl ?? "").replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
|
|
4386
|
+
const res = await fetchImpl(absolute, {
|
|
4387
|
+
method,
|
|
4388
|
+
headers: {
|
|
4389
|
+
"Content-Type": "application/json",
|
|
4390
|
+
...options.headers ?? {},
|
|
4391
|
+
...headers ?? {}
|
|
4392
|
+
},
|
|
4393
|
+
body: body ? JSON.stringify(body) : void 0
|
|
4394
|
+
});
|
|
4395
|
+
const text = await res.text();
|
|
4396
|
+
const parsed = text ? safeJsonParse(text) : null;
|
|
4397
|
+
if (!res.ok) {
|
|
4398
|
+
const msg = parsed && typeof parsed === "object" && "error" in parsed ? parsed.error : text;
|
|
4399
|
+
throw new Error(`${method} ${absolute} \u2192 ${res.status}: ${typeof msg === "string" ? msg : JSON.stringify(msg)}`);
|
|
4400
|
+
}
|
|
4401
|
+
return parsed;
|
|
4402
|
+
}
|
|
4403
|
+
};
|
|
4404
|
+
}
|
|
4405
|
+
function safeJsonParse(s) {
|
|
4406
|
+
try {
|
|
4407
|
+
return JSON.parse(s);
|
|
4408
|
+
} catch {
|
|
4409
|
+
return s;
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
3608
4412
|
async function registerActionsAsTools(registry, context) {
|
|
3609
4413
|
const objects = await context.metadata.listObjects();
|
|
3610
4414
|
const objMap = new Map(
|
|
@@ -3621,7 +4425,13 @@ async function registerActionsAsTools(registry, context) {
|
|
|
3621
4425
|
...action,
|
|
3622
4426
|
objectName: action.objectName ?? obj.name
|
|
3623
4427
|
};
|
|
3624
|
-
const reason = actionSkipReason(normalized
|
|
4428
|
+
const reason = actionSkipReason(normalized, {
|
|
4429
|
+
automation: context.automation,
|
|
4430
|
+
apiClient: context.apiClient,
|
|
4431
|
+
apiBaseUrl: context.apiBaseUrl,
|
|
4432
|
+
enableActionApproval: context.enableActionApproval,
|
|
4433
|
+
aiService: context.aiService
|
|
4434
|
+
});
|
|
3625
4435
|
if (reason !== null) {
|
|
3626
4436
|
skipped.push({ action: normalized.name, reason });
|
|
3627
4437
|
continue;
|
|
@@ -3632,8 +4442,33 @@ async function registerActionsAsTools(registry, context) {
|
|
|
3632
4442
|
skipped.push({ action: normalized.name, reason: "tool name already registered" });
|
|
3633
4443
|
continue;
|
|
3634
4444
|
}
|
|
3635
|
-
|
|
4445
|
+
const handler = createActionToolHandler(normalized, context);
|
|
4446
|
+
registry.register(definition, handler);
|
|
3636
4447
|
registered.push(definition.name);
|
|
4448
|
+
if (context.enableActionApproval && actionRequiresApproval(normalized) && context.aiService?.registerPendingActionDispatcher) {
|
|
4449
|
+
const bypassCtx = {
|
|
4450
|
+
...context,
|
|
4451
|
+
enableActionApproval: false
|
|
4452
|
+
};
|
|
4453
|
+
const directHandler = createActionToolHandler(normalized, bypassCtx);
|
|
4454
|
+
context.aiService.registerPendingActionDispatcher(
|
|
4455
|
+
definition.name,
|
|
4456
|
+
async (input) => {
|
|
4457
|
+
const raw = await directHandler(input);
|
|
4458
|
+
let parsed = raw;
|
|
4459
|
+
try {
|
|
4460
|
+
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
4461
|
+
} catch {
|
|
4462
|
+
parsed = raw;
|
|
4463
|
+
}
|
|
4464
|
+
if (parsed && typeof parsed === "object" && "ok" in parsed && parsed.ok === false) {
|
|
4465
|
+
const errMsg = parsed.error != null ? String(parsed.error) : "action handler reported failure";
|
|
4466
|
+
throw new Error(errMsg);
|
|
4467
|
+
}
|
|
4468
|
+
return parsed;
|
|
4469
|
+
}
|
|
4470
|
+
);
|
|
4471
|
+
}
|
|
3637
4472
|
}
|
|
3638
4473
|
}
|
|
3639
4474
|
return { registered, skipped };
|
|
@@ -4511,26 +5346,29 @@ var AIServicePlugin = class {
|
|
|
4511
5346
|
ctx.logger.info(`[AI] ModelRegistry initialised with ${modelRegistry.size} model(s)`);
|
|
4512
5347
|
}
|
|
4513
5348
|
let traceRecorder;
|
|
5349
|
+
let dataEngine;
|
|
5350
|
+
try {
|
|
5351
|
+
const engine = ctx.getService("data");
|
|
5352
|
+
if (engine && typeof engine.insert === "function") {
|
|
5353
|
+
dataEngine = engine;
|
|
5354
|
+
}
|
|
5355
|
+
} catch {
|
|
5356
|
+
}
|
|
4514
5357
|
if (this.options.traceRecorder === null) {
|
|
4515
5358
|
ctx.logger.debug("[AI] Tracing disabled (traceRecorder=null)");
|
|
4516
5359
|
} else if (this.options.traceRecorder) {
|
|
4517
5360
|
traceRecorder = this.options.traceRecorder;
|
|
4518
|
-
} else {
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
if (engine && typeof engine.insert === "function") {
|
|
4522
|
-
traceRecorder = new ObjectQLTraceRecorder(engine, { logger: ctx.logger });
|
|
4523
|
-
ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
|
|
4524
|
-
}
|
|
4525
|
-
} catch {
|
|
4526
|
-
}
|
|
5361
|
+
} else if (dataEngine) {
|
|
5362
|
+
traceRecorder = new ObjectQLTraceRecorder(dataEngine, { logger: ctx.logger });
|
|
5363
|
+
ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
|
|
4527
5364
|
}
|
|
4528
5365
|
const config = {
|
|
4529
5366
|
adapter,
|
|
4530
5367
|
logger: ctx.logger,
|
|
4531
5368
|
conversationService,
|
|
4532
5369
|
modelRegistry,
|
|
4533
|
-
traceRecorder
|
|
5370
|
+
traceRecorder,
|
|
5371
|
+
dataEngine
|
|
4534
5372
|
};
|
|
4535
5373
|
this.service = new AIService(config);
|
|
4536
5374
|
if (hasExisting) {
|
|
@@ -4545,8 +5383,8 @@ var AIServicePlugin = class {
|
|
|
4545
5383
|
type: "plugin",
|
|
4546
5384
|
scope: "project",
|
|
4547
5385
|
namespace: "ai",
|
|
4548
|
-
objects: [AiConversationObject, AiMessageObject, AiTraceObject],
|
|
4549
|
-
views: [AiTraceView]
|
|
5386
|
+
objects: [AiConversationObject, AiMessageObject, AiTraceObject, AiPendingActionObject],
|
|
5387
|
+
views: [AiTraceView, AiPendingActionView]
|
|
4550
5388
|
});
|
|
4551
5389
|
if (this.options.debug) {
|
|
4552
5390
|
ctx.hook("ai:beforeChat", async (messages) => {
|
|
@@ -4586,11 +5424,24 @@ var AIServicePlugin = class {
|
|
|
4586
5424
|
});
|
|
4587
5425
|
ctx.logger.info("[AI] query_data tool registered");
|
|
4588
5426
|
try {
|
|
5427
|
+
let automation;
|
|
5428
|
+
try {
|
|
5429
|
+
automation = ctx.getService("automation");
|
|
5430
|
+
} catch {
|
|
5431
|
+
automation = void 0;
|
|
5432
|
+
}
|
|
5433
|
+
const apiBaseUrl = this.options.apiActionBaseUrl ?? process.env.OS_AI_ACTION_API_BASE_URL;
|
|
5434
|
+
const apiHeaders = this.options.apiActionHeaders;
|
|
4589
5435
|
const { registered, skipped } = await registerActionsAsTools(
|
|
4590
5436
|
this.service.toolRegistry,
|
|
4591
5437
|
{
|
|
4592
5438
|
metadata: metadataService,
|
|
4593
|
-
dataEngine
|
|
5439
|
+
dataEngine,
|
|
5440
|
+
automation,
|
|
5441
|
+
apiBaseUrl,
|
|
5442
|
+
apiHeaders,
|
|
5443
|
+
enableActionApproval: this.options.enableActionApproval ?? false,
|
|
5444
|
+
aiService: this.service
|
|
4594
5445
|
}
|
|
4595
5446
|
);
|
|
4596
5447
|
if (registered.length > 0) {
|
|
@@ -4771,6 +5622,9 @@ var AIServicePlugin = class {
|
|
|
4771
5622
|
const toolRoutes = buildToolRoutes(this.service, ctx.logger);
|
|
4772
5623
|
routes.push(...toolRoutes);
|
|
4773
5624
|
ctx.logger.info(`[AI] Tool routes registered (${toolRoutes.length} routes)`);
|
|
5625
|
+
const pendingRoutes = buildPendingActionRoutes(this.service, ctx.logger);
|
|
5626
|
+
routes.push(...pendingRoutes);
|
|
5627
|
+
ctx.logger.info(`[AI] Pending-action routes registered (${pendingRoutes.length} routes)`);
|
|
4774
5628
|
if (metadataService) {
|
|
4775
5629
|
const skillRegistry = new SkillRegistry(metadataService);
|
|
4776
5630
|
const agentRuntime = new AgentRuntime(metadataService, skillRegistry);
|