@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.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
- where: z.record(z.string(), z.unknown()).optional().describe(
3247
- 'Filter conditions as key-value pairs. Use MongoDB-style operators for ranges, e.g. {"amount": {"$gt": 100}}.'
3893
+ whereJson: z.string().nullable().describe(
3894
+ 'Filter conditions encoded as a JSON object string. Examples: `{"status":"completed"}`, `{"subject":{"$contains":"Build"}}`, `{"amount":{"$gt":100}}`. Pass null to match all records.'
3248
3895
  ),
3249
- fields: z.array(z.string()).optional().describe("Field names to return. Omit to return all fields."),
3896
+ fields: z.array(z.string()).nullable().describe("Field names to return. Pass null to return all fields."),
3250
3897
  orderBy: z.array(
3251
3898
  z.object({
3252
3899
  field: z.string(),
3253
3900
  order: z.enum(["asc", "desc"])
3254
3901
  })
3255
- ).optional().describe("Sort order. First entry is primary sort key."),
3256
- limit: z.number().int().min(1).max(200).optional().describe("Maximum number of records (default 20, max 200).")
3902
+ ).nullable().describe("Sort order. First entry is primary sort key. Pass null for no sort."),
3903
+ limit: z.number().int().min(1).max(200).nullable().describe("Maximum number of records (default 20, max 200). Pass null for default.")
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, model } = args;
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: "You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\n" + snippet
3943
+ content: 'You translate user data questions into a single ObjectQL query plan. Use ONLY the objects and fields listed in the schema context below. Never invent field names. If the question is ambiguous, pick the most likely interpretation and use a reasonable `limit`.\n\nFilter operator hints:\n \u2022 For partial string matches (e.g. "task named Foo", "find X"), use case-insensitive substring matching with `$contains`: `{"subject": {"$contains": "Foo"}}`. Do NOT use equality unless the user clearly supplied the exact full value.\n \u2022 For numeric/date ranges use `$gt` / `$gte` / `$lt` / `$lte`.\n \u2022 For "is one of" use `$in: [...]`.\n \u2022 For exact equality just write the value: `{"status": "completed"}`.\n\n' + snippet
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: plan.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 actionSkipReason(action) {
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 !== "script") return `type='${action.type}' not yet supported`;
3354
- if (!action.target && !action.body) return "no target or body";
3355
- if (action.confirmText) return "requires confirmation (confirmText set)";
3356
- if (action.mode === "delete") return "mode='delete' \u2014 destructive";
3357
- if (action.variant === "danger") return "variant='danger' \u2014 destructive";
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 (actionSkipReason(action) !== null) return null;
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) => engine.delete(object, { where: { id: ids.length === 1 ? ids[0] : { $in: 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 handler.";
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
- const handlerCtx = {
3519
- record,
3520
- user: principal,
3521
- engine: engineAdapter,
3522
- params: userParams
3523
- };
3524
- const out = await ctx.dataEngine.executeAction?.(objectName, target, handlerCtx);
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
- registry.register(definition, createActionToolHandler(normalized, context));
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
- try {
4448
- const engine = ctx.getService("data");
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);