@objectstack/service-ai 6.1.1 → 6.3.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.js
CHANGED
|
@@ -1087,7 +1087,12 @@ function pickActionTool(userText, actionTools) {
|
|
|
1087
1087
|
"notify",
|
|
1088
1088
|
"publish",
|
|
1089
1089
|
"unpublish",
|
|
1090
|
-
"mark"
|
|
1090
|
+
"mark",
|
|
1091
|
+
"delete",
|
|
1092
|
+
"remove",
|
|
1093
|
+
"purge",
|
|
1094
|
+
"destroy",
|
|
1095
|
+
"erase"
|
|
1091
1096
|
]);
|
|
1092
1097
|
const hasActionVerb = [...userTokens].some((t) => ACTION_VERBS.has(t));
|
|
1093
1098
|
if (!hasActionVerb) return null;
|
|
@@ -1392,12 +1397,20 @@ function finishPart(result) {
|
|
|
1392
1397
|
}
|
|
1393
1398
|
var _AIService = class _AIService {
|
|
1394
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();
|
|
1395
1407
|
this.adapter = config.adapter ?? new MemoryLLMAdapter();
|
|
1396
1408
|
this.logger = config.logger ?? createLogger({ level: "info", format: "pretty" });
|
|
1397
1409
|
this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
|
|
1398
1410
|
this.conversationService = config.conversationService ?? new InMemoryConversationService();
|
|
1399
1411
|
this.modelRegistry = config.modelRegistry;
|
|
1400
1412
|
this.traceRecorder = config.traceRecorder ?? new NullTraceRecorder();
|
|
1413
|
+
this.dataEngine = config.dataEngine;
|
|
1401
1414
|
this.logger.info(
|
|
1402
1415
|
`[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}, models=${this.modelRegistry?.size ?? 0}`
|
|
1403
1416
|
);
|
|
@@ -1671,11 +1684,144 @@ var _AIService = class _AIService {
|
|
|
1671
1684
|
yield textDeltaPart("stream", result.content);
|
|
1672
1685
|
yield finishPart(result);
|
|
1673
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
|
+
}
|
|
1674
1815
|
};
|
|
1675
1816
|
// ── Tool Call Loop ────────────────────────────────────────────
|
|
1676
1817
|
/** Default maximum iterations for the tool call loop. */
|
|
1677
1818
|
_AIService.DEFAULT_MAX_ITERATIONS = 10;
|
|
1678
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
|
+
}
|
|
1679
1825
|
|
|
1680
1826
|
// src/stream/vercel-stream-encoder.ts
|
|
1681
1827
|
function sse(data) {
|
|
@@ -2560,6 +2706,132 @@ function buildToolRoutes(aiService, logger) {
|
|
|
2560
2706
|
];
|
|
2561
2707
|
}
|
|
2562
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
|
+
|
|
2563
2835
|
// src/conversation/objectql-conversation-service.ts
|
|
2564
2836
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2565
2837
|
var CONVERSATIONS_OBJECT = "ai_conversations";
|
|
@@ -3030,6 +3302,149 @@ var AiTraceObject = ObjectSchema3.create({
|
|
|
3030
3302
|
}
|
|
3031
3303
|
});
|
|
3032
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
|
+
|
|
3033
3448
|
// src/views/ai-trace.view.ts
|
|
3034
3449
|
import { defineView } from "@objectstack/spec";
|
|
3035
3450
|
var AiTraceView = defineView({
|
|
@@ -3087,6 +3502,238 @@ var AiTraceView = defineView({
|
|
|
3087
3502
|
}
|
|
3088
3503
|
});
|
|
3089
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
|
+
|
|
3090
3737
|
// src/plugin.ts
|
|
3091
3738
|
init_data_tools();
|
|
3092
3739
|
init_metadata_tools();
|
|
@@ -3243,17 +3890,17 @@ function describeField(field) {
|
|
|
3243
3890
|
// src/tools/query-data.tool.ts
|
|
3244
3891
|
var QueryPlanSchema = z.object({
|
|
3245
3892
|
objectName: z.string().min(1).describe('The snake_case object name to query (e.g. "task", "account").'),
|
|
3246
|
-
|
|
3247
|
-
'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.'
|
|
3248
3895
|
),
|
|
3249
|
-
fields: z.array(z.string()).
|
|
3896
|
+
fields: z.array(z.string()).nullable().describe("Field names to return. Pass null to return all fields."),
|
|
3250
3897
|
orderBy: z.array(
|
|
3251
3898
|
z.object({
|
|
3252
3899
|
field: z.string(),
|
|
3253
3900
|
order: z.enum(["asc", "desc"])
|
|
3254
3901
|
})
|
|
3255
|
-
).
|
|
3256
|
-
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.")
|
|
3257
3904
|
});
|
|
3258
3905
|
var QUERY_DATA_TOOL = {
|
|
3259
3906
|
name: "query_data",
|
|
@@ -3264,10 +3911,6 @@ var QUERY_DATA_TOOL = {
|
|
|
3264
3911
|
request: {
|
|
3265
3912
|
type: "string",
|
|
3266
3913
|
description: "The natural-language question to answer (paraphrase the user's request if needed for clarity)."
|
|
3267
|
-
},
|
|
3268
|
-
model: {
|
|
3269
|
-
type: "string",
|
|
3270
|
-
description: "Optional model id to use for query planning. Defaults to the AI service's default model."
|
|
3271
3914
|
}
|
|
3272
3915
|
},
|
|
3273
3916
|
required: ["request"],
|
|
@@ -3278,7 +3921,7 @@ function createQueryDataHandler(ctx) {
|
|
|
3278
3921
|
const retriever = new SchemaRetriever(ctx.metadata);
|
|
3279
3922
|
const maxLimit = ctx.maxLimit ?? 100;
|
|
3280
3923
|
return async (args) => {
|
|
3281
|
-
const { request
|
|
3924
|
+
const { request } = args;
|
|
3282
3925
|
if (!request || typeof request !== "string") {
|
|
3283
3926
|
return JSON.stringify({ error: "query_data: `request` is required" });
|
|
3284
3927
|
}
|
|
@@ -3297,14 +3940,13 @@ function createQueryDataHandler(ctx) {
|
|
|
3297
3940
|
const planMessages = [
|
|
3298
3941
|
{
|
|
3299
3942
|
role: "system",
|
|
3300
|
-
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
|
|
3301
3944
|
},
|
|
3302
3945
|
{ role: "user", content: request }
|
|
3303
3946
|
];
|
|
3304
3947
|
let plan;
|
|
3305
3948
|
try {
|
|
3306
3949
|
const generated = await ctx.ai.generateObject(planMessages, QueryPlanSchema, {
|
|
3307
|
-
model,
|
|
3308
3950
|
schemaName: "ObjectQLQueryPlan",
|
|
3309
3951
|
schemaDescription: "A single ObjectQL find() query to answer the user request."
|
|
3310
3952
|
});
|
|
@@ -3321,15 +3963,34 @@ function createQueryDataHandler(ctx) {
|
|
|
3321
3963
|
});
|
|
3322
3964
|
}
|
|
3323
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
|
+
}
|
|
3324
3985
|
try {
|
|
3325
3986
|
const records = await ctx.dataEngine.find(plan.objectName, {
|
|
3326
|
-
where
|
|
3327
|
-
fields: plan.fields,
|
|
3328
|
-
orderBy: plan.orderBy,
|
|
3987
|
+
where,
|
|
3988
|
+
fields: plan.fields ?? void 0,
|
|
3989
|
+
orderBy: plan.orderBy ?? void 0,
|
|
3329
3990
|
limit
|
|
3330
3991
|
});
|
|
3331
3992
|
return JSON.stringify({
|
|
3332
|
-
plan,
|
|
3993
|
+
plan: { ...plan, where },
|
|
3333
3994
|
count: records.length,
|
|
3334
3995
|
records
|
|
3335
3996
|
});
|
|
@@ -3346,15 +4007,43 @@ function registerQueryDataTool(registry, context) {
|
|
|
3346
4007
|
}
|
|
3347
4008
|
|
|
3348
4009
|
// src/tools/action-tools.ts
|
|
3349
|
-
function
|
|
4010
|
+
function actionRequiresApproval(action) {
|
|
4011
|
+
return Boolean(
|
|
4012
|
+
action.confirmText || action.mode === "delete" || action.variant === "danger"
|
|
4013
|
+
);
|
|
4014
|
+
}
|
|
4015
|
+
function actionSkipReason(action, ctx) {
|
|
3350
4016
|
if (action.aiExposed === false) {
|
|
3351
4017
|
return "opted-out via aiExposed:false";
|
|
3352
4018
|
}
|
|
3353
|
-
if (action.type
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
if (action.
|
|
3357
|
-
|
|
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
|
+
}
|
|
3358
4047
|
return null;
|
|
3359
4048
|
}
|
|
3360
4049
|
function fieldTypeToJsonType(t) {
|
|
@@ -3453,7 +4142,8 @@ function describeAction(action, ownerObject) {
|
|
|
3453
4142
|
return parts.join(" ");
|
|
3454
4143
|
}
|
|
3455
4144
|
function actionToToolDefinition(action, ownerObject, allObjects, toolPrefix = "action_") {
|
|
3456
|
-
if (
|
|
4145
|
+
if (action.aiExposed === false) return null;
|
|
4146
|
+
if (action.type === "url" || action.type === "modal" || action.type === "form") return null;
|
|
3457
4147
|
return {
|
|
3458
4148
|
name: actionToolName(action, toolPrefix),
|
|
3459
4149
|
description: describeAction(action, ownerObject),
|
|
@@ -3465,12 +4155,19 @@ function buildHandlerEngineAdapter(engine) {
|
|
|
3465
4155
|
update: (object, id, data) => engine.update(object, { ...data, id }, { where: { id } }),
|
|
3466
4156
|
insert: (object, data) => engine.insert(object, data),
|
|
3467
4157
|
find: (object, where) => engine.find(object, { where }),
|
|
3468
|
-
delete: (object, ids) =>
|
|
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
|
+
}
|
|
3469
4167
|
};
|
|
3470
4168
|
}
|
|
3471
4169
|
function createActionToolHandler(action, ctx) {
|
|
3472
4170
|
const principal = ctx.principal ?? { id: "ai_agent", name: "AI Assistant" };
|
|
3473
|
-
const engineAdapter = buildHandlerEngineAdapter(ctx.dataEngine);
|
|
3474
4171
|
const requiresRecord = Array.isArray(action.locations) && action.locations.some(
|
|
3475
4172
|
(l) => l === "list_item" || l === "record_header" || l === "record_more" || l === "record_related"
|
|
3476
4173
|
);
|
|
@@ -3486,8 +4183,8 @@ function createActionToolHandler(action, ctx) {
|
|
|
3486
4183
|
result.error = "Action has no objectName; cannot dispatch.";
|
|
3487
4184
|
return JSON.stringify(result);
|
|
3488
4185
|
}
|
|
3489
|
-
if (!target) {
|
|
3490
|
-
result.error = "Action has no target
|
|
4186
|
+
if (!target && action.type !== "script") {
|
|
4187
|
+
result.error = "Action has no target.";
|
|
3491
4188
|
return JSON.stringify(result);
|
|
3492
4189
|
}
|
|
3493
4190
|
const recordId = typeof args.recordId === "string" && args.recordId.length > 0 ? args.recordId : void 0;
|
|
@@ -3514,14 +4211,40 @@ function createActionToolHandler(action, ctx) {
|
|
|
3514
4211
|
}
|
|
3515
4212
|
}
|
|
3516
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
|
+
}
|
|
3517
4239
|
try {
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
}
|
|
3524
|
-
|
|
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
|
+
}
|
|
3525
4248
|
result.ok = true;
|
|
3526
4249
|
result.result = out ?? null;
|
|
3527
4250
|
const successMsg = typeof action.successMessage === "string" ? action.successMessage : void 0;
|
|
@@ -3533,6 +4256,87 @@ function createActionToolHandler(action, ctx) {
|
|
|
3533
4256
|
}
|
|
3534
4257
|
};
|
|
3535
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
|
+
}
|
|
3536
4340
|
async function registerActionsAsTools(registry, context) {
|
|
3537
4341
|
const objects = await context.metadata.listObjects();
|
|
3538
4342
|
const objMap = new Map(
|
|
@@ -3549,7 +4353,13 @@ async function registerActionsAsTools(registry, context) {
|
|
|
3549
4353
|
...action,
|
|
3550
4354
|
objectName: action.objectName ?? obj.name
|
|
3551
4355
|
};
|
|
3552
|
-
const reason = actionSkipReason(normalized
|
|
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
|
+
});
|
|
3553
4363
|
if (reason !== null) {
|
|
3554
4364
|
skipped.push({ action: normalized.name, reason });
|
|
3555
4365
|
continue;
|
|
@@ -3560,8 +4370,33 @@ async function registerActionsAsTools(registry, context) {
|
|
|
3560
4370
|
skipped.push({ action: normalized.name, reason: "tool name already registered" });
|
|
3561
4371
|
continue;
|
|
3562
4372
|
}
|
|
3563
|
-
|
|
4373
|
+
const handler = createActionToolHandler(normalized, context);
|
|
4374
|
+
registry.register(definition, handler);
|
|
3564
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
|
+
}
|
|
3565
4400
|
}
|
|
3566
4401
|
}
|
|
3567
4402
|
return { registered, skipped };
|
|
@@ -4439,26 +5274,29 @@ var AIServicePlugin = class {
|
|
|
4439
5274
|
ctx.logger.info(`[AI] ModelRegistry initialised with ${modelRegistry.size} model(s)`);
|
|
4440
5275
|
}
|
|
4441
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
|
+
}
|
|
4442
5285
|
if (this.options.traceRecorder === null) {
|
|
4443
5286
|
ctx.logger.debug("[AI] Tracing disabled (traceRecorder=null)");
|
|
4444
5287
|
} else if (this.options.traceRecorder) {
|
|
4445
5288
|
traceRecorder = this.options.traceRecorder;
|
|
4446
|
-
} else {
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
if (engine && typeof engine.insert === "function") {
|
|
4450
|
-
traceRecorder = new ObjectQLTraceRecorder(engine, { logger: ctx.logger });
|
|
4451
|
-
ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
|
|
4452
|
-
}
|
|
4453
|
-
} catch {
|
|
4454
|
-
}
|
|
5289
|
+
} else if (dataEngine) {
|
|
5290
|
+
traceRecorder = new ObjectQLTraceRecorder(dataEngine, { logger: ctx.logger });
|
|
5291
|
+
ctx.logger.info("[AI] Using ObjectQLTraceRecorder (IDataEngine detected)");
|
|
4455
5292
|
}
|
|
4456
5293
|
const config = {
|
|
4457
5294
|
adapter,
|
|
4458
5295
|
logger: ctx.logger,
|
|
4459
5296
|
conversationService,
|
|
4460
5297
|
modelRegistry,
|
|
4461
|
-
traceRecorder
|
|
5298
|
+
traceRecorder,
|
|
5299
|
+
dataEngine
|
|
4462
5300
|
};
|
|
4463
5301
|
this.service = new AIService(config);
|
|
4464
5302
|
if (hasExisting) {
|
|
@@ -4473,8 +5311,8 @@ var AIServicePlugin = class {
|
|
|
4473
5311
|
type: "plugin",
|
|
4474
5312
|
scope: "project",
|
|
4475
5313
|
namespace: "ai",
|
|
4476
|
-
objects: [AiConversationObject, AiMessageObject, AiTraceObject],
|
|
4477
|
-
views: [AiTraceView]
|
|
5314
|
+
objects: [AiConversationObject, AiMessageObject, AiTraceObject, AiPendingActionObject],
|
|
5315
|
+
views: [AiTraceView, AiPendingActionView]
|
|
4478
5316
|
});
|
|
4479
5317
|
if (this.options.debug) {
|
|
4480
5318
|
ctx.hook("ai:beforeChat", async (messages) => {
|
|
@@ -4514,11 +5352,24 @@ var AIServicePlugin = class {
|
|
|
4514
5352
|
});
|
|
4515
5353
|
ctx.logger.info("[AI] query_data tool registered");
|
|
4516
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;
|
|
4517
5363
|
const { registered, skipped } = await registerActionsAsTools(
|
|
4518
5364
|
this.service.toolRegistry,
|
|
4519
5365
|
{
|
|
4520
5366
|
metadata: metadataService,
|
|
4521
|
-
dataEngine
|
|
5367
|
+
dataEngine,
|
|
5368
|
+
automation,
|
|
5369
|
+
apiBaseUrl,
|
|
5370
|
+
apiHeaders,
|
|
5371
|
+
enableActionApproval: this.options.enableActionApproval ?? false,
|
|
5372
|
+
aiService: this.service
|
|
4522
5373
|
}
|
|
4523
5374
|
);
|
|
4524
5375
|
if (registered.length > 0) {
|
|
@@ -4699,6 +5550,9 @@ var AIServicePlugin = class {
|
|
|
4699
5550
|
const toolRoutes = buildToolRoutes(this.service, ctx.logger);
|
|
4700
5551
|
routes.push(...toolRoutes);
|
|
4701
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)`);
|
|
4702
5556
|
if (metadataService) {
|
|
4703
5557
|
const skillRegistry = new SkillRegistry(metadataService);
|
|
4704
5558
|
const agentRuntime = new AgentRuntime(metadataService, skillRegistry);
|