@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 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
- where: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional().describe(
3319
- 'Filter conditions as key-value pairs. Use MongoDB-style operators for ranges, e.g. {"amount": {"$gt": 100}}.'
3965
+ whereJson: import_zod.z.string().nullable().describe(
3966
+ 'Filter conditions encoded as a JSON object string. Examples: `{"status":"completed"}`, `{"subject":{"$contains":"Build"}}`, `{"amount":{"$gt":100}}`. Pass null to match all records.'
3320
3967
  ),
3321
- fields: import_zod.z.array(import_zod.z.string()).optional().describe("Field names to return. Omit to return all fields."),
3968
+ fields: import_zod.z.array(import_zod.z.string()).nullable().describe("Field names to return. Pass null to return all fields."),
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
- ).optional().describe("Sort order. First entry is primary sort key."),
3328
- limit: import_zod.z.number().int().min(1).max(200).optional().describe("Maximum number of records (default 20, max 200).")
3974
+ ).nullable().describe("Sort order. First entry is primary sort key. Pass null for no sort."),
3975
+ limit: import_zod.z.number().int().min(1).max(200).nullable().describe("Maximum number of records (default 20, max 200). Pass null for default.")
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, model } = args;
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: "You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\n" + snippet
4015
+ content: 'You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\nFilter operator hints:\n \u2022 For partial string matches (e.g. "task named Foo", "find X"), use case-insensitive substring matching with `$contains`: `{"subject": {"$contains": "Foo"}}`. Do NOT use equality unless the user clearly supplied the exact full value.\n \u2022 For numeric/date ranges use `$gt` / `$gte` / `$lt` / `$lte`.\n \u2022 For "is one of" use `$in: [...]`.\n \u2022 For exact equality just write the value: `{"status": "completed"}`.\n\n' + snippet
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: plan.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 actionSkipReason(action) {
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 !== "script") return `type='${action.type}' not yet supported`;
3426
- if (!action.target && !action.body) return "no target or body";
3427
- if (action.confirmText) return "requires confirmation (confirmText set)";
3428
- if (action.mode === "delete") return "mode='delete' \u2014 destructive";
3429
- if (action.variant === "danger") return "variant='danger' \u2014 destructive";
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 (actionSkipReason(action) !== null) return null;
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) => engine.delete(object, { where: { id: ids.length === 1 ? ids[0] : { $in: 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 handler.";
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
- const handlerCtx = {
3591
- record,
3592
- user: principal,
3593
- engine: engineAdapter,
3594
- params: userParams
3595
- };
3596
- const out = await ctx.dataEngine.executeAction?.(objectName, target, handlerCtx);
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
- registry.register(definition, createActionToolHandler(normalized, context));
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
- try {
4520
- const engine = ctx.getService("data");
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);