Skip to content
Open
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
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 4.3.8
- Added a `mutate_model_record` tool so the fixture OpenAI agent can create records through `DataAccessor` with the caller's permissions.
- Limited the fixture AI assistant to the OpenAI data agent and updated seeded access rights for the new token.
- Documented the JSON payload format for agent-driven mutations.

## 4.3.7
- Added `DataAccessor.describeAccessibleFields()` to expose per-action field metadata for AI and form builders.
- Extended the fixture OpenAI agent with schema introspection, payload sanitisation, and required-field validation when creating records.
Expand Down
25 changes: 25 additions & 0 deletions docs/AiAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,34 @@ OpenAI's Agents API while still relying on Adminizer's abstractions:
`AbstractAiModelService`.
* Database reads are performed through `DataAccessor`, which means the usual access control and field
sanitisation rules are enforced automatically.
* Record creation is also performed through the same accessor using the `mutate_model_record`
tool. The agent builds a JSON payload with the caller's permissions, validates required fields,
and persists the record on behalf of the user.
* Conversation history is converted into the `@openai/agents` protocol so follow-up questions can
build on previous answers.

### Creating Records Through the Agent

When the user requests a new record the agent calls the `mutate_model_record` tool with a payload
similar to the following:

```json
{
"action": "create",
"model": "Example",
"payload": {
"title": "Launch checklist",
"description": "Kick-off tasks captured by the agent",
"sort": true
}
}
```

The payload is sanitised against the current user's `DataAccessor` permissions, so any disabled or
restricted fields are ignored automatically. Required fields are validated before the mutation runs
and a descriptive error is returned to the chat if anything is missing. Once the tool succeeds the
assistant summarises the action and returns the created record identifier to the user.

To enable the agent locally, set the following environment variables before starting the fixture:

```bash
Expand Down
4 changes: 2 additions & 2 deletions fixture/adminizerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,8 @@ const config: AdminpanelConfig = {
},
aiAssistant: {
enabled: true,
defaultModel: 'openai',
models: ['openai'],
defaultModel: 'openai-data',
models: ['openai-data'],
},
routePrefix: routePrefix,
// routePrefix: "/admin",
Expand Down
130 changes: 124 additions & 6 deletions fixture/helpers/ai/OpenAiDataAgentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService {
},
fields: {
type: 'array',
items: { type: 'string', minLength: 1 },
items: {type: 'string', minLength: 1},
description: 'Optional list of fields to include in the response'
},
limit: {
Expand All @@ -102,7 +102,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService {
description: 'Maximum number of records to return (default 10).'
}
},
required: ['model', 'filter', 'fields', 'limit'],
required: ['model'],
additionalProperties: false
},
execute: async (input: any, runContext?: RunContext<AgentContext>) => {
Expand Down Expand Up @@ -140,19 +140,78 @@ export class OpenAiDataAgentService extends AbstractAiModelService {
},
});

const mutateRecordTool = tool({
name: 'mutate_model_record',
description: 'Create Adminizer records using DataAccessor with the caller\'s permissions.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
description: 'Mutation action to perform (currently only "create" is supported).',
enum: ['create'],
default: 'create',
},
model: {
type: 'string',
description: 'Model name as defined in the Adminizer configuration.',
minLength: 1,
},
payload: {
description: 'Record fields as an object or JSON string.',
oneOf: [
{type: 'object'},
{type: 'string'},
],
},
},
required: ['model', 'payload'],
additionalProperties: false,
},
execute: async (input: any, runContext?: RunContext<AgentContext>) => {
const activeUser = runContext?.context?.user ?? user;

if (!input.model) {
throw new Error('Model name is required');
}

const entity = this.resolveEntity(input.model);
if (!entity.model) {
throw new Error(`Model "${input.model}" is not registered in Adminizer.`);
}

const action = (input.action ?? 'create') as 'create';
if (action !== 'create') {
throw new Error(`Unsupported mutation action: ${action}`);
}

const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add');
const sanitizedPayload = await this.prepareCreatePayload(input.payload, accessor);

const created = await entity.model.create(sanitizedPayload, accessor);

return JSON.stringify({
model: entity.name,
action,
record: created,
}, null, 2);
},
});

return new Agent<AgentContext>({
name: 'Adminizer data agent',
instructions: [
'You are an assistant that answers questions using Adminizer data.',
'Always rely on the provided tool to inspect database records.',
'Only include fields that are relevant to the question.',
'Summaries should explain how the answer was derived from the data.',
'Always rely on the provided tools to inspect or modify database records.',
'Prefer concise JSON outputs for tool calls and provide human-readable summaries afterwards.',
'Only include fields that are relevant to the request and double-check required values before creating records.',
'Summaries should explain how the answer was derived from the data or confirm the performed mutation.',
'',
'Accessible models:',
modelSummary,
].join('\n'),
handoffDescription: 'Retrieves Adminizer records using DataAccessor with full permission checks.',
tools: [dataQueryTool],
tools: [dataQueryTool, mutateRecordTool],
model: this.model,
});
}
Expand All @@ -166,6 +225,65 @@ export class OpenAiDataAgentService extends AbstractAiModelService {
}, {});
}

private async prepareCreatePayload(
rawPayload: unknown,
accessor: DataAccessor,
): Promise<Record<string, unknown>> {
const parsedPayload = this.normalizePayload(rawPayload);
const fieldsConfig = accessor.getFieldsConfig();

if (!fieldsConfig) {
throw new Error('You do not have permission to create records for this model.');
}

const allowedKeys = Object.keys(fieldsConfig);
const sanitized: Record<string, unknown> = {};

for (const key of allowedKeys) {
if (Object.prototype.hasOwnProperty.call(parsedPayload, key)) {
sanitized[key] = parsedPayload[key];
}
}

const missingRequired = Object.entries(fieldsConfig)
.filter(([_, config]) => Boolean(config?.config?.required))
.map(([key]) => key)
.filter((key) => {
const value = sanitized[key];
return value === undefined || value === null || value === '';
});

if (missingRequired.length > 0) {
throw new Error(`Missing required fields: ${missingRequired.join(', ')}`);
}

return sanitized;
}

private normalizePayload(rawPayload: unknown): Record<string, unknown> {
if (typeof rawPayload === 'string') {
try {
const parsed = JSON.parse(rawPayload);
return this.ensureRecord(parsed);
} catch {
throw new Error('Payload must be a valid JSON object string.');
}
}

return this.ensureRecord(rawPayload);
}

private ensureRecord(value: unknown): Record<string, unknown> {
const schema = z.record(z.any());
const parsed = schema.safeParse(value);

if (!parsed.success || Array.isArray(parsed.data)) {
throw new Error('Payload must be an object with field values.');
}

return parsed.data;
}

private toAgentInput(history: AiAssistantMessage[]): AgentInputItem[] {
return history.map<AgentInputItem>((message) => {
if (message.role === 'user') {
Expand Down
6 changes: 2 additions & 4 deletions fixture/helpers/seedDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ export async function seedDatabase(
// ------------------ Groups ------------------ //
const groupNames = [
{ name: 'Admins', description: 'System administrators', tokens: [
'ai-assistant-dummy',
'ai-assistant-openai',
'ai-assistant-openai-data',
] },
{ name: 'Users', description: 'Registered users', tokens:
[
Expand All @@ -43,8 +42,7 @@ export async function seedDatabase(
"update-example-model",

"read-jsonschema-model",
"ai-assistant-dummy",
"ai-assistant-openai",
"ai-assistant-openai-data",
]
},
{ name: 'Guests', description: 'Guest access' },
Expand Down
6 changes: 5 additions & 1 deletion fixture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import adminpanelConfig from "./adminizerConfig";
import {AdminpanelConfig} from "../dist/interfaces/adminpanelConfig";
import {sendNotificationsWithDelay} from "./helpers/notifications";
import {OpenAiDataAgentService} from "./helpers/ai/OpenAiDataAgentService";
import {AiAssistantHandler} from "../dist/lib/ai-assistant/AiAssistantHandler";

import {ReactQuill} from "../modules/controls/wysiwyg/ReactQuill";

Expand Down Expand Up @@ -181,9 +182,12 @@ async function ormSharedFixtureLift(adminizer: Adminizer) {
if (adminizer.config.aiAssistant?.enabled) {
const openAiAgent = new OpenAiDataAgentService(adminizer);
if (openAiAgent.isEnabled()) {
// Re-create the handler so only the data agent is exposed in the fixture.
adminizer.aiAssistantHandler = new AiAssistantHandler(adminizer);
adminizer.aiAssistantHandler.registerModel(openAiAgent);

// Set as default model
// Reflect the active model in the runtime configuration so the UI shows a single option.
adminizer.config.aiAssistant.models = [openAiAgent.id];
adminizer.config.aiAssistant.defaultModel = openAiAgent.id;

console.log(`[fixture] OpenAI data agent successfully registered with ID: ${openAiAgent.id}`);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/DataAccessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,8 @@ export class DataAccessor {
return null;
}

const targetModel = (field.model?.model ?? field.model?.collection ?? field.model?.ref) as string | undefined;
const modelReference = field.model as {model?: string; collection?: string; ref?: string} | undefined;
const targetModel = (modelReference?.model ?? modelReference?.collection ?? modelReference?.ref) as string | undefined;

if (!targetModel) {
return null;
Expand Down