Skip to content

Commit a46c017

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(ai): actions opt in to AI tools via ai: block (ADR-0011) (#1581)
Realign ADR-0011 with its original opt-in design and finalise the implementation. An Action becomes an AI-callable tool only when it sets `ai.exposed: true`, which requires an explicit LLM-facing `ai.description` (distinct from the UI `label`). No heuristic auto-exposure, no label-derived descriptions — a clean break from the first implementation's opt-out `aiExposed` flag, which is removed outright (no compat shim; not yet shipped). - spec: ActionAiSchema (exposed/description/category/paramHints/outputSchema/ requiresConfirmation) + refines; remove aiExposed; extend AIToolDefinition with category/outputSchema/objectName/requiresConfirmation; update action.form. - service-ai: bridge gates on opt-in, uses ai.description, merges paramHints, summarises outputSchema into the description, honours requiresConfirmation override, and warns on exposed destructive actions asserted safe. - examples/app-todo: migrate script actions to opt-in (testbed). - docs: rewrite ADR-0011 to Accepted/Implemented with the AI-author rationale. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3306d2f commit a46c017

12 files changed

Lines changed: 757 additions & 354 deletions

File tree

.changeset/actions-as-ai-tools.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-ai': patch
4+
---
5+
6+
feat(ai): actions opt in to being AI tools via an `ai:` block (ADR-0011)
7+
8+
Realigns ADR-0011 with its original opt-in design. An Action becomes an
9+
AI-callable tool only when its metadata sets `ai.exposed: true`, which requires
10+
an explicit, LLM-facing `ai.description` (≥40 chars, distinct from the UI
11+
`label`). There is no heuristic auto-exposure and no description derived from
12+
the label — a clean break from the first implementation's opt-out `aiExposed`
13+
flag, which is removed (no compatibility shim; the platform has not shipped).
14+
15+
The `ai:` block also carries `category`, `paramHints` (per-parameter JSON-Schema
16+
refinement), `outputSchema` (summarised into the tool description for chaining),
17+
and `requiresConfirmation` (overrides the destructive-action HITL default).
18+
`AIToolDefinition` is extended to carry `category` / `outputSchema` / `objectName`
19+
/ `requiresConfirmation`. The `@objectstack/service-ai` bridge
20+
(`action-tools.ts`) now gates on opt-in, merges `paramHints`, and emits a lint
21+
warning when an exposed destructive-looking action asserts itself safe via
22+
`ai.requiresConfirmation: false`.

docs/adr/0011-actions-as-ai-tools.md

Lines changed: 210 additions & 290 deletions
Large diffs are not rendered by default.

examples/app-todo/src/actions/task.actions.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export const CompleteTaskAction: Action = {
1313
locations: ['record_header', 'list_item'],
1414
successMessage: 'Task marked as complete!',
1515
refreshAfter: true,
16+
ai: {
17+
exposed: true,
18+
description: 'Mark a todo task as complete. Use when the user says a task is done or finished.',
19+
},
1620
};
1721

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

3139
/** Defer Task */
@@ -87,6 +95,10 @@ export const CloneTaskAction: Action = {
8795
locations: ['record_header'],
8896
successMessage: 'Task cloned successfully!',
8997
refreshAfter: true,
98+
ai: {
99+
exposed: true,
100+
description: 'Duplicate an existing todo task, copying its fields into a new task record.',
101+
},
90102
};
91103

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

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

123146
/** Export Tasks to CSV */
@@ -131,4 +154,8 @@ export const ExportToCsvAction: Action = {
131154
locations: ['list_toolbar'],
132155
successMessage: 'Export completed!',
133156
refreshAfter: false,
157+
ai: {
158+
exposed: true,
159+
description: 'Export the current list of todo tasks to a downloadable CSV file.',
160+
},
134161
};

examples/app-todo/test/ai-action.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// AI **action** integration demo — the write-side counterpart to
4-
// `ai-agent.test.ts`. Confirms that every `type: 'script'` action on
5-
// the Task object is auto-registered as an `action_<name>` tool, and
6-
// that the `data_chat` agent can pick the right one in plain English.
4+
// `ai-agent.test.ts`. Confirms that every `type: 'script'` action on the
5+
// Task object that opts in via `ai.exposed` (ADR-0011) is registered as an
6+
// `action_<name>` tool, and that the `data_chat` agent can pick the right
7+
// one in plain English.
78
//
89
// Run via: `pnpm --filter @example/app-todo test:action`
910
//
@@ -189,7 +190,7 @@ import { registerTaskActionHandlers } from '../src/actions/register-handlers';
189190
}
190191

191192
console.log('\n🎉 Action Demo Successful!');
192-
console.log(' • Script-type actions auto-exposed as `action_*` tools');
193+
console.log(' • Opted-in script actions (ai.exposed) registered as `action_*` tools');
193194
console.log(' • Agent routed user request to action_complete_task');
194195
console.log(' • Task status mutated from incomplete → completed');
195196
console.log(' • chat_with_tools trace persisted in ai_traces');

packages/services/service-ai/src/__tests__/action-tools.test.ts

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { describe, it, expect, vi } from 'vitest';
44
import type { Action } from '@objectstack/spec/ui';
55
import {
66
actionSkipReason,
7+
actionToToolDefinition,
78
buildApiRequestBody,
89
createFetchApiClient,
910
registerActionsAsTools,
1011
type ApiActionClient,
1112
} from '../tools/action-tools.js';
1213
import { ToolRegistry } from '../tools/tool-registry.js';
1314

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

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

55-
it('respects aiExposed:false', () => {
56-
expect(actionSkipReason(baseAction({ aiExposed: false }))).toMatch(/aiExposed/);
59+
it('is opt-in: skips actions that did not set ai.exposed', () => {
60+
expect(actionSkipReason(baseAction({ ai: undefined }))).toMatch(/not AI-exposed/);
61+
expect(actionSkipReason(baseAction({ ai: { exposed: false } as never }))).toMatch(/not AI-exposed/);
62+
});
63+
64+
it('skips an exposed action missing a description (defensive)', () => {
65+
expect(
66+
actionSkipReason(baseAction({ ai: { exposed: true } as never })),
67+
).toMatch(/description is missing/);
68+
});
69+
70+
it('ai.requiresConfirmation:false lets an exposed destructive action run', () => {
71+
// delete looks destructive, but the author asserts it is safe → exposed.
72+
expect(
73+
actionSkipReason(baseAction({
74+
mode: 'delete',
75+
ai: { exposed: true, description: 'Archive this task record; it is reversible from trash.', requiresConfirmation: false },
76+
})),
77+
).toBeNull();
78+
});
79+
80+
it('ai.requiresConfirmation:true gates an otherwise-safe action behind HITL', () => {
81+
const a = baseAction({
82+
ai: { exposed: true, description: 'Update the task title to the value the user supplied.', requiresConfirmation: true },
83+
});
84+
expect(actionSkipReason(a)).toMatch(/requires confirmation/);
85+
expect(
86+
actionSkipReason(a, {
87+
enableActionApproval: true,
88+
aiService: { proposePendingAction: async () => ({ id: 'x' }) },
89+
}),
90+
).toBeNull();
91+
});
92+
});
93+
94+
describe('actionToToolDefinition — ai: block translation', () => {
95+
it('returns null when not exposed', () => {
96+
expect(actionToToolDefinition(baseAction({ ai: undefined }), undefined, new Map())).toBeNull();
97+
});
98+
99+
it('uses ai.description and carries category/objectName/requiresConfirmation', () => {
100+
const def = actionToToolDefinition(
101+
baseAction({ ai: { exposed: true, description: 'Triage a support case and suggest a priority and queue.', category: 'analytics' } }),
102+
undefined,
103+
new Map(),
104+
);
105+
expect(def).not.toBeNull();
106+
expect(def!.description).toContain('Triage a support case');
107+
expect(def!.category).toBe('analytics');
108+
expect(def!.objectName).toBe('task');
109+
expect(def!.requiresConfirmation).toBe(false);
110+
});
111+
112+
it('summarises ai.outputSchema into the description and carries it through', () => {
113+
const outputSchema = {
114+
type: 'object',
115+
properties: { priority: { type: 'string' }, queue: { type: 'string' } },
116+
};
117+
const def = actionToToolDefinition(
118+
baseAction({ ai: { exposed: true, description: 'Triage a support case and return a structured suggestion.', outputSchema } }),
119+
undefined,
120+
new Map(),
121+
);
122+
expect(def!.outputSchema).toEqual(outputSchema);
123+
expect(def!.description).toMatch(/Returns an object with: priority, queue\./);
124+
});
125+
126+
it('merges ai.paramHints into the parameter JSON Schema', () => {
127+
const def = actionToToolDefinition(
128+
baseAction({
129+
params: [{ name: 'priority', type: 'text' }],
130+
ai: {
131+
exposed: true,
132+
description: 'Set the priority on the task record to one of the allowed values.',
133+
paramHints: { priority: { description: 'One of P0-P3.', enum: ['P0', 'P1', 'P2', 'P3'] } },
134+
},
135+
}),
136+
undefined,
137+
new Map(),
138+
);
139+
const props = (def!.parameters as { properties: Record<string, Record<string, unknown>> }).properties;
140+
expect(props.priority.enum).toEqual(['P0', 'P1', 'P2', 'P3']);
141+
expect(props.priority.description).toBe('One of P0-P3.');
57142
});
58143
});
59144

@@ -372,3 +457,33 @@ describe('actionRequiresApproval + HITL queue routing', () => {
372457
expect((result as any).result).toEqual({ deleted: true });
373458
});
374459
});
460+
461+
describe('lint guardrail — asserted-safe destructive actions', () => {
462+
it('registers but warns when a destructive action sets ai.requiresConfirmation:false', async () => {
463+
const reg = new ToolRegistry();
464+
const action = baseAction({
465+
name: 'archive_task',
466+
mode: 'delete',
467+
type: 'script',
468+
target: 'archiveTaskHandler',
469+
locations: [],
470+
params: [],
471+
ai: {
472+
exposed: true,
473+
description: 'Archive this task record; the operation is reversible from the trash.',
474+
requiresConfirmation: false,
475+
},
476+
} as Partial<Action>);
477+
const objects = [{ name: 'task', label: 'Task', fields: {}, actions: [action] }];
478+
const { registered, skipped, warnings } = await registerActionsAsTools(reg, {
479+
metadata: { listObjects: async () => objects } as never,
480+
dataEngine: { find: async () => [], executeAction: async () => ({ ok: true }) } as never,
481+
} as never);
482+
483+
expect(skipped).toEqual([]);
484+
expect(registered).toEqual(['action_archive_task']);
485+
expect(warnings).toHaveLength(1);
486+
expect(warnings[0].action).toBe('archive_task');
487+
expect(warnings[0].warning).toMatch(/without human approval/);
488+
});
489+
});

packages/services/service-ai/src/objects/ai-pending-action.object.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ export const AiPendingActionObject = ObjectSchema.create({
151151
variant: 'primary',
152152
confirmText: 'Approve and execute this action now?',
153153
successMessage: 'Action approved and executed.',
154-
// The approval click is the operator's authorisation gesture —
155-
// the LLM must not be allowed to bypass HITL by approving itself.
156-
aiExposed: false,
154+
// Human-only by design: not opted into AI (no `ai.exposed`). The approval
155+
// click is the operator's authorisation gesture — the LLM must not be
156+
// able to bypass HITL by approving itself.
157157
},
158158
{
159159
name: 'reject_pending_action',
@@ -165,7 +165,7 @@ export const AiPendingActionObject = ObjectSchema.create({
165165
variant: 'danger',
166166
confirmText: 'Reject this pending action? It will not be executed.',
167167
successMessage: 'Action rejected.',
168-
aiExposed: false,
168+
// Human-only by design: not opted into AI (no `ai.exposed`).
169169
},
170170
],
171171

packages/services/service-ai/src/plugin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@ export class AIServicePlugin implements Plugin {
666666
const apiBaseUrl =
667667
this.options.apiActionBaseUrl ?? process.env.OS_AI_ACTION_API_BASE_URL;
668668
const apiHeaders = this.options.apiActionHeaders;
669-
const { registered, skipped } = await registerActionsAsTools(
669+
const { registered, skipped, warnings } = await registerActionsAsTools(
670670
this.service.toolRegistry,
671671
{
672672
metadata: metadataService,
@@ -689,6 +689,9 @@ export class AIServicePlugin implements Plugin {
689689
{ skipped },
690690
);
691691
}
692+
for (const w of warnings) {
693+
ctx.logger.warn(`[AI] action '${w.action}': ${w.warning}`);
694+
}
692695
} catch (err) {
693696
ctx.logger.warn(
694697
'[AI] Failed to register action tools',

0 commit comments

Comments
 (0)