Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/actions-as-ai-tools.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': patch
'@objectstack/service-ai': patch
---

feat(ai): actions opt in to being AI tools via an `ai:` block (ADR-0011)

Realigns ADR-0011 with its original opt-in design. An Action becomes an
AI-callable tool only when its metadata sets `ai.exposed: true`, which requires
an explicit, LLM-facing `ai.description` (≥40 chars, distinct from the UI
`label`). There is no heuristic auto-exposure and no description derived from
the label — a clean break from the first implementation's opt-out `aiExposed`
flag, which is removed (no compatibility shim; the platform has not shipped).

The `ai:` block also carries `category`, `paramHints` (per-parameter JSON-Schema
refinement), `outputSchema` (summarised into the tool description for chaining),
and `requiresConfirmation` (overrides the destructive-action HITL default).
`AIToolDefinition` is extended to carry `category` / `outputSchema` / `objectName`
/ `requiresConfirmation`. The `@objectstack/service-ai` bridge
(`action-tools.ts`) now gates on opt-in, merges `paramHints`, and emits a lint
warning when an exposed destructive-looking action asserts itself safe via
`ai.requiresConfirmation: false`.
500 changes: 210 additions & 290 deletions docs/adr/0011-actions-as-ai-tools.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions examples/app-todo/src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,10 @@ export const CompleteTaskAction: Action = {
locations: ['record_header', 'list_item'],
successMessage: 'Task marked as complete!',
refreshAfter: true,
ai: {
exposed: true,
description: 'Mark a todo task as complete. Use when the user says a task is done or finished.',
},
};

/** Mark Task as In Progress */
Expand All@@ -26,6 +30,10 @@ export const StartTaskAction: Action = {
locations: ['record_header', 'list_item'],
successMessage: 'Task started!',
refreshAfter: true,
ai: {
exposed: true,
description: 'Mark a todo task as in progress. Use when the user says they are starting or working on a task.',
},
};

/** Defer Task */
Expand DownExpand Up@@ -87,6 +95,10 @@ export const CloneTaskAction: Action = {
locations: ['record_header'],
successMessage: 'Task cloned successfully!',
refreshAfter: true,
ai: {
exposed: true,
description: 'Duplicate an existing todo task, copying its fields into a new task record.',
},
};

/** Mass Complete Tasks */
Expand All@@ -100,6 +112,10 @@ export const MassCompleteTasksAction: Action = {
locations: ['list_toolbar'],
successMessage: 'Selected tasks marked as complete!',
refreshAfter: true,
ai: {
exposed: true,
description: 'Mark all currently selected todo tasks as complete in one bulk operation.',
},
};

/** Delete Completed Tasks */
Expand All@@ -118,6 +134,13 @@ export const DeleteCompletedAction: Action = {
confirmText: 'Permanently delete all completed tasks? This cannot be undone.',
successMessage: 'Completed tasks deleted!',
refreshAfter: true,
ai: {
exposed: true,
description:
'Permanently delete every completed todo task. Destructive and irreversible — only after the user confirms.',
// confirmText + variant:'danger' default this to requiring HITL approval;
// it registers only when enableActionApproval is on, then routes to the queue.
},
};

/** Export Tasks to CSV */
Expand All@@ -131,4 +154,8 @@ export const ExportToCsvAction: Action = {
locations: ['list_toolbar'],
successMessage: 'Export completed!',
refreshAfter: false,
ai: {
exposed: true,
description: 'Export the current list of todo tasks to a downloadable CSV file.',
},
};
9 changes: 5 additions & 4 deletions examples/app-todo/test/ai-action.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// AI **action** integration demo — the write-side counterpart to
// `ai-agent.test.ts`. Confirms that every `type: 'script'` action on
// the Task object is auto-registered as an `action_<name>` tool, and
// that the `data_chat` agent can pick the right one in plain English.
// `ai-agent.test.ts`. Confirms that every `type: 'script'` action on the
// Task object that opts in via `ai.exposed` (ADR-0011) is registered as an
// `action_<name>` tool, and that the `data_chat` agent can pick the right
// one in plain English.
//
// Run via: `pnpm --filter @example/app-todo test:action`
//
Expand DownExpand Up@@ -189,7 +190,7 @@ import { registerTaskActionHandlers } from '../src/actions/register-handlers';
}

console.log('\n🎉 Action Demo Successful!');
console.log(' • Script-type actions auto-exposed as `action_*` tools');
console.log(' • Opted-in script actions (ai.exposed) registered as `action_*` tools');
console.log(' • Agent routed user request to action_complete_task');
console.log(' • Task status mutated from incomplete → completed');
console.log(' • chat_with_tools trace persisted in ai_traces');
Expand Down
119 changes: 117 additions & 2 deletions packages/services/service-ai/src/__tests__/action-tools.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,13 +4,16 @@ import { describe, it, expect, vi } from 'vitest';
import type { Action } from '@objectstack/spec/ui';
import {
actionSkipReason,
actionToToolDefinition,
buildApiRequestBody,
createFetchApiClient,
registerActionsAsTools,
type ApiActionClient,
} from '../tools/action-tools.js';
import { ToolRegistry } from '../tools/tool-registry.js';

// Actions are AI-exposed only by opt-in (ADR-0011), so the baseline fixture
// carries a valid `ai` block. Tests that exercise the opt-in gate override it.
const baseAction = (over: Partial<Action> = {}): Action =>
({
name: 'do_thing',
Expand All@@ -19,6 +22,7 @@ const baseAction = (over: Partial<Action> = {}): Action =>
target: 'doThingHandler',
objectName: 'task',
locations: ['record_header'],
ai: { exposed: true, description: 'Do the thing the user asked for on this task record.' },
...over,
}) as Action;

Expand DownExpand Up@@ -52,8 +56,89 @@ describe('actionSkipReason', () => {
expect(actionSkipReason(baseAction({ variant: 'danger' }))).toMatch(/danger/);
});

it('respects aiExposed:false', () => {
expect(actionSkipReason(baseAction({ aiExposed: false }))).toMatch(/aiExposed/);
it('is opt-in: skips actions that did not set ai.exposed', () => {
expect(actionSkipReason(baseAction({ ai: undefined }))).toMatch(/not AI-exposed/);
expect(actionSkipReason(baseAction({ ai: { exposed: false } as never }))).toMatch(/not AI-exposed/);
});

it('skips an exposed action missing a description (defensive)', () => {
expect(
actionSkipReason(baseAction({ ai: { exposed: true } as never })),
).toMatch(/description is missing/);
});

it('ai.requiresConfirmation:false lets an exposed destructive action run', () => {
// delete looks destructive, but the author asserts it is safe → exposed.
expect(
actionSkipReason(baseAction({
mode: 'delete',
ai: { exposed: true, description: 'Archive this task record; it is reversible from trash.', requiresConfirmation: false },
})),
).toBeNull();
});

it('ai.requiresConfirmation:true gates an otherwise-safe action behind HITL', () => {
const a = baseAction({
ai: { exposed: true, description: 'Update the task title to the value the user supplied.', requiresConfirmation: true },
});
expect(actionSkipReason(a)).toMatch(/requires confirmation/);
expect(
actionSkipReason(a, {
enableActionApproval: true,
aiService: { proposePendingAction: async () => ({ id: 'x' }) },
}),
).toBeNull();
});
});

describe('actionToToolDefinition — ai: block translation', () => {
it('returns null when not exposed', () => {
expect(actionToToolDefinition(baseAction({ ai: undefined }), undefined, new Map())).toBeNull();
});

it('uses ai.description and carries category/objectName/requiresConfirmation', () => {
const def = actionToToolDefinition(
baseAction({ ai: { exposed: true, description: 'Triage a support case and suggest a priority and queue.', category: 'analytics' } }),
undefined,
new Map(),
);
expect(def).not.toBeNull();
expect(def!.description).toContain('Triage a support case');
expect(def!.category).toBe('analytics');
expect(def!.objectName).toBe('task');
expect(def!.requiresConfirmation).toBe(false);
});

it('summarises ai.outputSchema into the description and carries it through', () => {
const outputSchema = {
type: 'object',
properties: { priority: { type: 'string' }, queue: { type: 'string' } },
};
const def = actionToToolDefinition(
baseAction({ ai: { exposed: true, description: 'Triage a support case and return a structured suggestion.', outputSchema } }),
undefined,
new Map(),
);
expect(def!.outputSchema).toEqual(outputSchema);
expect(def!.description).toMatch(/Returns an object with: priority, queue\./);
});

it('merges ai.paramHints into the parameter JSON Schema', () => {
const def = actionToToolDefinition(
baseAction({
params: [{ name: 'priority', type: 'text' }],
ai: {
exposed: true,
description: 'Set the priority on the task record to one of the allowed values.',
paramHints: { priority: { description: 'One of P0-P3.', enum: ['P0', 'P1', 'P2', 'P3'] } },
},
}),
undefined,
new Map(),
);
const props = (def!.parameters as { properties: Record<string, Record<string, unknown>> }).properties;
expect(props.priority.enum).toEqual(['P0', 'P1', 'P2', 'P3']);
expect(props.priority.description).toBe('One of P0-P3.');
});
});

Expand DownExpand Up@@ -372,3 +457,33 @@ describe('actionRequiresApproval + HITL queue routing', () => {
expect((result as any).result).toEqual({ deleted: true });
});
});

describe('lint guardrail — asserted-safe destructive actions', () => {
it('registers but warns when a destructive action sets ai.requiresConfirmation:false', async () => {
const reg = new ToolRegistry();
const action = baseAction({
name: 'archive_task',
mode: 'delete',
type: 'script',
target: 'archiveTaskHandler',
locations: [],
params: [],
ai: {
exposed: true,
description: 'Archive this task record; the operation is reversible from the trash.',
requiresConfirmation: false,
},
} as Partial<Action>);
const objects = [{ name: 'task', label: 'Task', fields: {}, actions: [action] }];
const { registered, skipped, warnings } = await registerActionsAsTools(reg, {
metadata: { listObjects: async () => objects } as never,
dataEngine: { find: async () => [], executeAction: async () => ({ ok: true }) } as never,
} as never);

expect(skipped).toEqual([]);
expect(registered).toEqual(['action_archive_task']);
expect(warnings).toHaveLength(1);
expect(warnings[0].action).toBe('archive_task');
expect(warnings[0].warning).toMatch(/without human approval/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -151,9 +151,9 @@ export const AiPendingActionObject = ObjectSchema.create({
variant: 'primary',
confirmText: 'Approve and execute this action now?',
successMessage: 'Action approved and executed.',
// The approval click is the operator's authorisation gesture —
// the LLM must not be allowed to bypass HITL by approving itself.
aiExposed: false,
// Human-only by design: not opted into AI (no `ai.exposed`). The approval
// click is the operator's authorisation gesture — the LLM must not be
// able to bypass HITL by approving itself.
},
{
name: 'reject_pending_action',
Expand All@@ -165,7 +165,7 @@ export const AiPendingActionObject = ObjectSchema.create({
variant: 'danger',
confirmText: 'Reject this pending action? It will not be executed.',
successMessage: 'Action rejected.',
aiExposed: false,
// Human-only by design: not opted into AI (no `ai.exposed`).
},
],

Expand Down
5 changes: 4 additions & 1 deletion packages/services/service-ai/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -666,7 +666,7 @@ export class AIServicePlugin implements Plugin {
const apiBaseUrl =
this.options.apiActionBaseUrl ?? process.env.OS_AI_ACTION_API_BASE_URL;
const apiHeaders = this.options.apiActionHeaders;
const { registered, skipped } = await registerActionsAsTools(
const { registered, skipped, warnings } = await registerActionsAsTools(
this.service.toolRegistry,
{
metadata: metadataService,
Expand All@@ -689,6 +689,9 @@ export class AIServicePlugin implements Plugin {
{ skipped },
);
}
for (const w of warnings) {
ctx.logger.warn(`[AI] action '${w.action}': ${w.warning}`);
}
} catch (err) {
ctx.logger.warn(
'[AI] Failed to register action tools',
Expand Down
Loading