Skip to content

Commit b4c74a9

Browse files
committed
feat(ai): add HITL approval queue for dangerous actions
1 parent 15e650c commit b4c74a9

20 files changed

Lines changed: 1291 additions & 25 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
WebContainer (StackBlitz) signup compatibility: `AuthManager` now auto-detects
6+
WebContainer runtimes at construction time and swaps better-auth's default
7+
`node:crypto.scrypt`-based password hasher for the pure-JS hasher from
8+
`@better-auth/utils/password` (which uses `@noble/hashes/scrypt` under the
9+
hood).
10+
11+
**Why:** WebContainer's `node:crypto` polyfill ships an incomplete `scrypt`
12+
implementation that throws `TypeError: y.run is not a function` on every
13+
signup, blocking template demos on StackBlitz. The pure-JS implementation is
14+
byte-compatible with the Node hasher (same scrypt params, same `salt:keyHex`
15+
storage format), so accounts created under either hasher remain mutually
16+
verifiable — no migration, no template changes.
17+
18+
**Scope:** detection short-circuits to `undefined` on real Node, so production
19+
deployments are completely unaffected — the JS fallback module is only
20+
dynamically imported when one of `process.versions.webcontainer`,
21+
`SHELL` containing `jsh`, or `STACKBLITZ` env is present.
22+
23+
Templates (`@template/todo`, `@template/contracts`, …) require no changes;
24+
the fix lives entirely inside `@objectstack/plugin-auth`.

.changeset/v5-ai-hitl-approval.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/service-ai': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
**Actions-as-tools Phase 3 — Human-In-The-Loop approval queue.**
7+
8+
Dangerous declarative actions (`confirmText`, `mode:'delete'`, `variant:'danger'`) can now be exposed to the LLM safely. Instead of being skipped outright, they are registered as tools whose handler enqueues a pending request and returns `{ status: 'pending_approval', pendingActionId }` to the model. A human approves (or rejects) from Studio's pending-actions inbox; the service then re-runs the exact same dispatcher.
9+
10+
### New surface
11+
12+
- New system object `ai_pending_actions` (id, conversation_id?, message_id?, object_name, action_name, tool_name, tool_input, status [`pending`|`approved`|`executed`|`failed`|`rejected`], result?, error?, rejection_reason?, proposed_by, decided_by?, proposed_at, decided_at?).
13+
- New built-in Studio view `AiPendingActionView` with `pending` / `executed` / `rejected` / `failed` sub-views and per-row **Approve** / **Reject** API actions.
14+
- New methods on `IAIService` (all optional, gated on a wired `IDataEngine`):
15+
- `proposePendingAction(input) → { id }`
16+
- `approvePendingAction(id, actorId) → { status, result?, error? }`
17+
- `rejectPendingAction(id, actorId, reason?)`
18+
- `listPendingActions(filter?) → PendingActionRow[]`
19+
- New exported types: `PendingActionStatus`, `ProposePendingActionInput`, `PendingActionRow`.
20+
- New REST routes (auth required):
21+
- `GET /api/v1/ai/pending-actions` (`ai:read`)
22+
- `GET /api/v1/ai/pending-actions/:id` (`ai:read`)
23+
- `POST /api/v1/ai/pending-actions/:id/approve` (`ai:approve`)
24+
- `POST /api/v1/ai/pending-actions/:id/reject` (`ai:approve`)
25+
- New exported predicate `actionRequiresApproval(action)` for Studio's exposure surface.
26+
27+
### Wiring
28+
29+
`AIServicePluginOptions` gains `enableActionApproval?: boolean` (default `false`). When `true` and an `IDataEngine` is available, dangerous actions are registered and routed through the queue.
30+
31+
```ts
32+
kernel.use(new AIServicePlugin({
33+
enableActionApproval: true, // opt in
34+
apiActionBaseUrl: 'http://localhost:3000',
35+
}));
36+
```
37+
38+
### Internals
39+
40+
- `actionSkipReason()` accepts `enableActionApproval` + `aiService` in its ctx and stops returning `"requires confirmation"` / `"mode='delete'"` / `"variant='danger'"` when HITL is wired.
41+
- `registerActionsAsTools()` pre-registers a *bypass-approval* dispatcher per dangerous tool via `aiService.registerPendingActionDispatcher(toolName, fn)`; approval calls back into the same code path with `enableActionApproval` flipped off, so a single handler implementation serves both proposal and execution.
42+
- `createActionToolHandler()` short-circuits to `proposePendingAction()` when `enableActionApproval && actionRequiresApproval(action) && ctx.aiService?.proposePendingAction`.
43+
44+
### Out of scope (deferred)
45+
46+
Slack/email notifications, approver routing (any signed-in user can approve in v1), auto-expiry of pending requests, resuming the same LLM turn after approval (operators get a fresh assistant message instead).

content/docs/guides/ai-capabilities.mdx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Complete guide to leveraging AI agents, RAG pipelines, and intelligent automatio
1212
1. [AI Architecture](#ai-architecture)
1313
2. [AI Agents](#ai-agents)
1414
3. [Actions as Tools (auto-exposed)](#actions-as-tools-auto-exposed)
15+
- [Human-In-The-Loop approval](#human-in-the-loop-approval)
1516
4. [RAG Pipelines](#rag-pipelines)
1617
5. [Natural Language Queries](#natural-language-queries)
1718
6. [Predictive Analytics](#predictive-analytics)
@@ -371,7 +372,7 @@ The AI runtime walks every registered object's `actions[]` and exposes the ones
371372
| `api` | HTTP call to `action.target` via `ApiActionClient` (default: `fetch`) | `apiBaseUrl` (or a custom `apiClient`) |
372373
| `flow` | `IAutomationService.execute(target, { triggerData })` | `automation` service registered |
373374

374-
Studio-only types (`url`, `modal`, `form`) and any action with `confirmText`, `mode: 'delete'`, or `variant: 'danger'` are skipped — those require Phase-3 HITL approval. Owners can also opt out per action with `aiExposed: false`.
375+
Studio-only types (`url`, `modal`, `form`) are always skipped. Dangerous actions — those with `confirmText`, `mode: 'delete'`, or `variant: 'danger'` are skipped **by default**, but can be opted into the [HITL approval queue](#human-in-the-loop-approval) so an operator authorises every call. Owners can also opt out per action with `aiExposed: false`.
375376

376377
### Wiring it up
377378

@@ -405,6 +406,33 @@ For api actions, the request body is built from three sources (last wins):
405406

406407
`registerActionsAsTools()` returns `{ registered, skipped }`. Studio surfaces the skipped list with reasons (e.g. `"no apiClient or apiBaseUrl configured"`, `"mode='delete' — destructive"`) so action authors can see at a glance whether their action will be LLM-callable.
407408

409+
### Human-In-The-Loop approval
410+
411+
Destructive actions are too risky to let the LLM execute autonomously, but locking them away entirely defeats agentic UX. The HITL queue strikes the balance: the LLM gets to **propose** the call, a human gets to **approve** it.
412+
413+
Enable the queue via the plugin option (default: off):
414+
415+
```typescript
416+
kernel.use(
417+
new AIServicePlugin({
418+
enableActionApproval: true,
419+
apiActionBaseUrl: process.env.OS_AI_ACTION_API_BASE_URL,
420+
}),
421+
);
422+
```
423+
424+
When the LLM picks an approval-gated tool (e.g. `action_delete_task`), the runtime:
425+
426+
1. Persists an `ai_pending_actions` row with `status:'pending'`, `tool_input`, `proposed_by`, etc.
427+
2. Returns `{ status: 'pending_approval', pendingActionId }` to the model so it can summarise (e.g. "I've requested approval to delete task #42.").
428+
3. The operator sees the proposal in the **AI Pending Actions** Studio inbox.
429+
4. Clicking **Approve** calls `POST /api/v1/ai/pending-actions/:id/approve` (`ai:approve` permission). The service looks up the pre-registered dispatcher and executes the action with HITL routing disabled, so the same code path runs. The row transitions to `executed` (or `failed` if dispatch threw).
430+
5. Clicking **Reject** calls `POST /api/v1/ai/pending-actions/:id/reject` with an optional reason.
431+
432+
The queue is exposed via four REST endpoints (`GET`, `GET/:id`, `POST/:id/approve`, `POST/:id/reject`) and via `IAIService.{proposePendingAction,approvePendingAction,rejectPendingAction,listPendingActions}` for programmatic use.
433+
434+
**Why a dedicated queue (not the multi-step `IApprovalService`)?** AI tool-call HITL is ephemeral: subject is the proposed *call*, not a stable record state; there's no predefined process; operators expect single-click yes/no. The pending-action queue is a thin write log + dispatcher map that delivers that UX without dragging in process-engine overhead.
435+
408436
---
409437

410438
## RAG Pipelines

content/docs/references/ai/skill.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const result = Skill.parse(data);
3737
| **label** | `string` || Skill display name |
3838
| **description** | `string` | optional | Skill description |
3939
| **instructions** | `string` | optional | LLM instructions when skill is active |
40-
| **tools** | `string[]` || Tool names belonging to this skill |
40+
| **tools** | `string[]` || Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) |
4141
| **triggerPhrases** | `string[]` | optional | Phrases that activate this skill |
4242
| **triggerConditions** | `Object[]` | optional | Programmatic activation conditions |
4343
| **permissions** | `string[]` | optional | Required permissions or roles |

packages/plugins/plugin-auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@better-auth/oauth-provider": "^1.6.11",
22+
"@better-auth/utils": "^0.4.0",
2223
"@objectstack/core": "workspace:*",
2324
"@objectstack/platform-objects": "workspace:*",
2425
"@objectstack/spec": "workspace:*",

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export class AuthManager {
154154
private async createAuthInstance(): Promise<Auth<any>> {
155155
const { betterAuth } = await import('better-auth');
156156
const plugins = await this.buildPluginList();
157+
const passwordHasher = await this.resolvePasswordHasher();
157158
const betterAuthConfig: BetterAuthOptions = {
158159
// Base configuration
159160
secret: this.config.secret || this.generateSecret(),
@@ -217,6 +218,7 @@ export class AuthManager {
217218
// Email and password configuration
218219
emailAndPassword: {
219220
enabled: this.config.emailAndPassword?.enabled ?? true,
221+
...(passwordHasher ? { password: passwordHasher } : {}),
220222
...(this.config.emailAndPassword?.disableSignUp != null
221223
? { disableSignUp: this.config.emailAndPassword.disableSignUp } : {}),
222224
...(this.config.emailAndPassword?.requireEmailVerification != null
@@ -360,6 +362,42 @@ export class AuthManager {
360362
return betterAuth(betterAuthConfig);
361363
}
362364

365+
/**
366+
* Detect WebContainer (StackBlitz) and swap in a pure-JS scrypt hasher.
367+
*
368+
* better-auth defaults to `@better-auth/utils/password.node`, which calls
369+
* `node:crypto.scrypt`. WebContainer polyfills that API incompletely and
370+
* signup throws `TypeError: y.run is not a function`. The pure-JS variant
371+
* at `@better-auth/utils/password` uses `@noble/hashes/scrypt` with
372+
* identical params (N=16384, r=16, p=1, dkLen=64) and emits the same
373+
* `{salt}:{keyHex}` format, so existing hashes remain verifiable.
374+
*
375+
* Returns `undefined` outside WebContainer so production deployments keep
376+
* the native (fast) hasher and never load the JS fallback.
377+
*/
378+
private async resolvePasswordHasher(): Promise<
379+
{ hash: (password: string) => Promise<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | undefined
380+
> {
381+
const isWebContainer =
382+
typeof globalThis !== 'undefined' &&
383+
(Boolean((globalThis as any).process?.versions?.webcontainer) ||
384+
Boolean((globalThis as any).process?.env?.SHELL?.includes?.('jsh')) ||
385+
Boolean((globalThis as any).process?.env?.STACKBLITZ));
386+
if (!isWebContainer) return undefined;
387+
try {
388+
const mod = await import('@better-auth/utils/password');
389+
return {
390+
hash: (password: string) => mod.hashPassword(password),
391+
verify: ({ hash, password }) => mod.verifyPassword(hash, password),
392+
};
393+
} catch (err: any) {
394+
console.warn(
395+
`[AuthManager] WebContainer detected but pure-JS password hasher unavailable: ${err?.message ?? err}. Falling back to default.`,
396+
);
397+
return undefined;
398+
}
399+
}
400+
363401
/**
364402
* Build the list of better-auth plugins based on AuthPluginConfig flags.
365403
*

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,92 @@ describe('registerActionsAsTools — api + flow dispatch', () => {
283283
expect(parsed.error).toMatch(/smtp down/);
284284
});
285285
});
286+
287+
describe('actionRequiresApproval + HITL queue routing', () => {
288+
it('skips dangerous actions when approval is NOT wired', () => {
289+
const a = baseAction({ mode: 'delete' });
290+
// No approval ctx → skipped
291+
expect(actionSkipReason(a)).toMatch(/delete/);
292+
expect(actionSkipReason(a, { enableActionApproval: false })).toMatch(/delete/);
293+
});
294+
295+
it('registers dangerous actions when approval IS wired', () => {
296+
const a = baseAction({ mode: 'delete' });
297+
expect(
298+
actionSkipReason(a, {
299+
enableActionApproval: true,
300+
aiService: { proposePendingAction: async () => ({ id: 'x' }) },
301+
}),
302+
).toBeNull();
303+
});
304+
305+
it('routes invocation to proposePendingAction + pre-registers dispatcher', async () => {
306+
const reg = new ToolRegistry();
307+
const propose = vi.fn(async (input: unknown) => {
308+
void input;
309+
return { id: 'pa_42' };
310+
});
311+
const dispatchers = new Map<string, (input: unknown) => Promise<unknown>>();
312+
const registerDispatcher = vi.fn((name: string, fn: (input: unknown) => Promise<unknown>) => {
313+
dispatchers.set(name, fn);
314+
});
315+
316+
let executed = false;
317+
const action = baseAction({
318+
name: 'delete_task',
319+
mode: 'delete',
320+
type: 'script',
321+
target: 'deleteTaskHandler',
322+
locations: [],
323+
params: [],
324+
} as Partial<Action>);
325+
const objects = [{ name: 'task', label: 'Task', fields: {}, actions: [action] }];
326+
327+
const { registered, skipped } = await registerActionsAsTools(reg, {
328+
metadata: { listObjects: async () => objects } as never,
329+
dataEngine: {
330+
find: async () => [],
331+
executeAction: async () => {
332+
executed = true;
333+
return { deleted: true };
334+
},
335+
} as never,
336+
enableActionApproval: true,
337+
aiService: {
338+
proposePendingAction: propose,
339+
registerPendingActionDispatcher: registerDispatcher,
340+
},
341+
} as never);
342+
343+
expect(skipped).toEqual([]);
344+
expect(registered).toEqual(['action_delete_task']);
345+
expect(registerDispatcher).toHaveBeenCalledWith('action_delete_task', expect.any(Function));
346+
347+
// LLM invokes the tool → should NOT execute, should queue.
348+
const r = await reg.execute({
349+
type: 'tool-call',
350+
toolCallId: 't1',
351+
toolName: 'action_delete_task',
352+
input: { recordId: 'rec_1' },
353+
} as never);
354+
const env = JSON.parse((r.output as { value: string }).value);
355+
expect(env.status).toBe('pending_approval');
356+
expect(env.pendingActionId).toBe('pa_42');
357+
expect(executed).toBe(false);
358+
expect(propose).toHaveBeenCalledTimes(1);
359+
const proposeArg = propose.mock.calls[0][0] as any;
360+
expect(proposeArg.objectName).toBe('task');
361+
expect(proposeArg.actionName).toBe('delete_task');
362+
expect(proposeArg.toolName).toBe('action_delete_task');
363+
expect(proposeArg.toolInput).toEqual({ recordId: 'rec_1' });
364+
365+
// Approval pathway: registered dispatcher should bypass HITL.
366+
const dispatcher = dispatchers.get('action_delete_task');
367+
expect(dispatcher).toBeDefined();
368+
const result = await dispatcher!({ recordId: 'rec_1' });
369+
expect(executed).toBe(true);
370+
// Dispatcher returns parsed envelope (helps AIService store structured result).
371+
expect((result as any).ok).toBe(true);
372+
expect((result as any).result).toEqual({ deleted: true });
373+
});
374+
});

0 commit comments

Comments
 (0)