@nuxtagentic/prompt 0.0.0-stage → 0.1.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.
Files changed (36) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +91 -2
  3. package/cloudconfig.ts +165 -0
  4. package/config.ts +33 -0
  5. package/core/decisions.ts +155 -0
  6. package/core/engine.ts +317 -0
  7. package/core/index.ts +3 -0
  8. package/core/planner.ts +67 -0
  9. package/core/types.ts +188 -0
  10. package/core/validation.ts +187 -0
  11. package/docs/search.md +69 -0
  12. package/module.ts +26 -0
  13. package/package.json +56 -3
  14. package/runtime/server/api/agent/cancel.post.ts +17 -0
  15. package/runtime/server/api/agent/execute.post.ts +26 -0
  16. package/runtime/server/api/agent/plan.post.ts +37 -0
  17. package/runtime/server/api/agent.post.ts +60 -0
  18. package/runtime/server/confirmations.ts +104 -0
  19. package/runtime/server/utils/agent-operations.ts +112 -0
  20. package/runtime/server/utils/agent-session.ts +14 -0
  21. package/runtime/server/utils/decision-arguments.ts +363 -0
  22. package/runtime/server/utils/decision-provider.ts +141 -0
  23. package/runtime/server/utils/jev-answers.ts +55 -0
  24. package/runtime/server/utils/jev-dispatch.ts +156 -0
  25. package/runtime/server/utils/jev-inference.ts +87 -0
  26. package/runtime/server/utils/jev-tool-plan.ts +64 -0
  27. package/runtime/server/utils/model-query-condition.ts +59 -0
  28. package/runtime/server/utils/model-query.ts +275 -0
  29. package/runtime/server/utils/prompt-candidates.ts +165 -0
  30. package/runtime/server/utils/prompt-dates.ts +83 -0
  31. package/runtime/shared/agent-tools.ts +50 -0
  32. package/runtime/shared/agent.ts +5 -0
  33. package/runtime/shared/decision-provider.ts +1 -0
  34. package/runtime/shared/prompt-config.ts +27 -0
  35. package/schema.ts +69 -0
  36. package/server-contributions.ts +53 -0
package/core/engine.ts ADDED
@@ -0,0 +1,317 @@
1
+ import {
2
+ OperationError,
3
+ type Context,
4
+ type ConfirmationStore,
5
+ type Planner,
6
+ type PlanResult,
7
+ type PreparedMutation,
8
+ type Resource,
9
+ type Receipt,
10
+ type Row,
11
+ } from './types'
12
+ import { toDisplayRow, computeFingerprint, isValidId, validateFilters } from './validation'
13
+
14
+ const MAX_RECORDS = 25
15
+
16
+ function writableInput(plan: PreparedMutation, resource: Resource) {
17
+ const fields = plan.operation === 'create' ? resource.createFields : resource.updateFields
18
+ return Object.fromEntries(Object.entries(plan.input).filter(([key]) => fields.includes(key)))
19
+ }
20
+
21
+ export function createOperationEngine(
22
+ resources: readonly Resource[],
23
+ planner: Planner,
24
+ store: ConfirmationStore,
25
+ ) {
26
+ const registry = new Map(resources.map((resource) => [resource.id, resource]))
27
+ const resourceFor = (id: string) => {
28
+ const resource = registry.get(id)
29
+ if (!resource) {
30
+ throw new OperationError(403, 'This resource is not available to the agent.')
31
+ }
32
+ return resource
33
+ }
34
+ const supports = (resource: Resource, operation: string) => {
35
+ if (!resource.operations.some((action) => action === operation)) {
36
+ throw new OperationError(403, `You cannot ${operation} ${resource.label.toLowerCase()}.`)
37
+ }
38
+ }
39
+
40
+ async function plan(prompt: string, context: Context): Promise<PlanResult> {
41
+ const route = await planner.route(prompt, resources, context)
42
+ if (!route.resource || !route.operation) {
43
+ return { kind: 'fallback' }
44
+ }
45
+ if (!Number.isFinite(route.confidence) || route.confidence < 0.8) {
46
+ return {
47
+ kind: 'clarification',
48
+ message: 'Name the resource and the action you want to perform.',
49
+ }
50
+ }
51
+ const resource = resourceFor(route.resource)
52
+ const operation = route.operation
53
+ supports(resource, operation)
54
+ // Input-dependent create rules run after extraction, never with guessed placeholder data.
55
+ if (operation === 'list') {
56
+ await resource.authorize(operation)
57
+ }
58
+ if (operation === 'create' && resource.createHandoff) {
59
+ return { kind: 'handoff', resource: resource.id, action: resource.createHandoff }
60
+ }
61
+ const proposal = await planner.extract(prompt, resource, operation, context)
62
+ if (!proposal.supported) {
63
+ return {
64
+ kind: 'clarification',
65
+ message:
66
+ proposal.clarification || 'Be more specific about the fields and records to change.',
67
+ }
68
+ }
69
+ const filters = validateFilters(proposal.filters, resource)
70
+ let id = proposal.id
71
+ if (id !== null && !isValidId(id)) {
72
+ throw new OperationError(422, 'Choose a valid record ID.')
73
+ }
74
+ if (proposal.useSelection) {
75
+ if (
76
+ id !== null ||
77
+ context.resource !== resource.id ||
78
+ !isValidId(context.selection) ||
79
+ filters.children.length
80
+ ) {
81
+ throw new OperationError(422, 'Select a record or provide an explicit filter.')
82
+ }
83
+ id = context.selection
84
+ }
85
+ if (id !== null && filters.children.length) {
86
+ throw new OperationError(422, 'Use a record ID or a filter, not both.')
87
+ }
88
+ if (
89
+ proposal.sortBy &&
90
+ !resource.fields.some(
91
+ (field) =>
92
+ field.key === proposal.sortBy &&
93
+ !['json', 'multiselect', 'relation'].includes(field.kind),
94
+ )
95
+ ) {
96
+ throw new OperationError(422, 'This sort field is unavailable.')
97
+ }
98
+ if (
99
+ proposal.limit !== null &&
100
+ (!Number.isSafeInteger(proposal.limit) || proposal.limit < 1 || proposal.limit > MAX_RECORDS)
101
+ ) {
102
+ throw new OperationError(422, `Request between 1 and ${MAX_RECORDS} records.`)
103
+ }
104
+ if (operation === 'create') {
105
+ const missing = resource.fields.filter((field) => {
106
+ const value = proposal.input[field.key]
107
+ return (
108
+ field.required &&
109
+ resource.createFields.includes(field.key) &&
110
+ (value === undefined || value === null || (typeof value === 'string' && !value.trim()))
111
+ )
112
+ })
113
+ if (missing.length) {
114
+ return {
115
+ kind: 'clarification',
116
+ message: `Provide ${missing.map((field) => field.label).join(' and ')} to create ${resource.label.toLowerCase()}.`,
117
+ }
118
+ }
119
+ }
120
+ const input =
121
+ operation === 'create' || operation === 'update'
122
+ ? resource.validateInput(operation, proposal.input)
123
+ : {}
124
+ if (operation === 'update' && !Object.keys(proposal.input).length) {
125
+ throw new OperationError(422, 'Specify the fields to update.')
126
+ }
127
+ if (operation === 'create') {
128
+ if (id !== null || filters.children.length || proposal.useSelection) {
129
+ throw new OperationError(422, 'A new record cannot have a target filter.')
130
+ }
131
+ await resource.authorize(operation, undefined, input)
132
+ return prepare(
133
+ { resource: resource.id, operation, input, targets: [], many: false },
134
+ [],
135
+ resource,
136
+ context,
137
+ )
138
+ }
139
+ const mutating = operation === 'update' || operation === 'delete'
140
+ if ((mutating || operation === 'read') && id === null && !filters.children.length) {
141
+ return {
142
+ kind: 'clarification',
143
+ message:
144
+ 'Select a record or add a filter, for example “delete user with email containing adriaansen”.',
145
+ }
146
+ }
147
+ // Mutations resolve the complete bounded match set. A user-supplied LIMIT must never hide targets.
148
+ const result =
149
+ id !== null
150
+ ? { data: [await resource.get(id)], totalCount: 1 }
151
+ : await resource.list({
152
+ page: 1,
153
+ pageSize: mutating ? MAX_RECORDS : (proposal.limit ?? MAX_RECORDS),
154
+ sortBy: proposal.sortBy ?? 'id',
155
+ sortDir: proposal.sortDir,
156
+ ...(filters.children.length ? { filters } : {}),
157
+ })
158
+ if (!mutating) {
159
+ // A filter resolves IDs through list access; read access can be narrower than list access.
160
+ const rows =
161
+ operation === 'read' && id === null
162
+ ? await Promise.all(result.data.map((row) => resource.get(row.id)))
163
+ : result.data
164
+ return {
165
+ kind: 'result',
166
+ resource: resource.id,
167
+ label: resource.label,
168
+ fields: resource.fields.filter((field) => resource.displayFields.includes(field.key)),
169
+ records: rows.map((row) => toDisplayRow(row, resource)),
170
+ totalCount: result.totalCount,
171
+ }
172
+ }
173
+ if (!result.totalCount) {
174
+ return { kind: 'clarification', message: 'No accessible records match that request.' }
175
+ }
176
+ if (result.totalCount > MAX_RECORDS || result.data.length !== result.totalCount) {
177
+ throw new OperationError(
178
+ 422,
179
+ `More than ${MAX_RECORDS} records match. Add a narrower filter before changing them.`,
180
+ )
181
+ }
182
+ if (proposal.limit !== null && proposal.limit < result.totalCount) {
183
+ throw new OperationError(422, 'Use a narrower filter instead of LIMIT for a mutation.')
184
+ }
185
+ for (const row of result.data) {
186
+ await resource.authorize(operation, row.id, input)
187
+ }
188
+ const targets = await Promise.all(
189
+ result.data.map(async (row) => ({ id: row.id, fingerprint: await computeFingerprint(row) })),
190
+ )
191
+ return prepare(
192
+ { resource: resource.id, operation, input, targets, many: proposal.many },
193
+ result.data,
194
+ resource,
195
+ context,
196
+ )
197
+ }
198
+
199
+ async function prepare(
200
+ prepared: PreparedMutation,
201
+ rows: Row[],
202
+ resource: Resource,
203
+ context: Context,
204
+ ): Promise<PlanResult> {
205
+ const confirmation = await store.create(prepared, context)
206
+ return {
207
+ kind: 'confirmation',
208
+ key: confirmation.key,
209
+ resource: resource.id,
210
+ label: resource.label,
211
+ operation: prepared.operation,
212
+ input: writableInput(prepared, resource),
213
+ records: rows.map((row) => toDisplayRow(row, resource)),
214
+ many: prepared.many,
215
+ expiresAt: confirmation.expiresAt,
216
+ fields: resource.fields.filter((field) => resource.displayFields.includes(field.key)),
217
+ }
218
+ }
219
+
220
+ async function execute(key: string, selectedIds: number[], context: Context): Promise<Receipt> {
221
+ const confirmation = await store.get(key, context)
222
+ if (confirmation.actorId !== context.actorId || confirmation.sessionId !== context.sessionId) {
223
+ throw new OperationError(403, 'This confirmation belongs to another session.')
224
+ }
225
+ // Retrying a lost response returns the saved receipt; it never repeats a mutation.
226
+ if (confirmation.result && ['completed', 'failed'].includes(confirmation.state)) {
227
+ return confirmation.result
228
+ }
229
+ if (confirmation.expiresAt <= Date.now()) {
230
+ throw new OperationError(409, 'This confirmation expired. Submit the request again.')
231
+ }
232
+ if (confirmation.state !== 'pending') {
233
+ throw new OperationError(
234
+ 409,
235
+ 'This action is already running or was cancelled. Submit a new request after checking the results.',
236
+ )
237
+ }
238
+ const prepared = confirmation.plan
239
+ const resource = resourceFor(prepared.resource)
240
+ const targets = new Map(prepared.targets.map((target) => [target.id, target]))
241
+ supports(resource, prepared.operation)
242
+ if (
243
+ selectedIds.some((id) => !isValidId(id)) ||
244
+ new Set(selectedIds).size !== selectedIds.length ||
245
+ selectedIds.some((id) => !targets.has(id)) ||
246
+ (prepared.operation === 'create' ? selectedIds.length !== 0 : !selectedIds.length) ||
247
+ (!prepared.many && prepared.operation !== 'create' && selectedIds.length !== 1)
248
+ ) {
249
+ throw new OperationError(
250
+ 422,
251
+ 'Confirm only the reviewed records. Choose one record for a singular request.',
252
+ )
253
+ }
254
+ const input =
255
+ prepared.operation === 'delete'
256
+ ? {}
257
+ : resource.validateInput(prepared.operation, writableInput(prepared, resource))
258
+ async function recheck(id: number) {
259
+ const target = targets.get(id)!
260
+ const current = await resource.get(id)
261
+ if ((await computeFingerprint(current)) !== target.fingerprint) {
262
+ throw new OperationError(
263
+ 409,
264
+ 'A reviewed record changed. Submit the request again to review its current values.',
265
+ )
266
+ }
267
+ await resource.authorize(prepared.operation, id, input)
268
+ }
269
+ // Check every target before the first write, then check each again immediately before its write.
270
+ if (prepared.operation === 'create') {
271
+ await resource.authorize('create', undefined, input)
272
+ } else {
273
+ for (const id of selectedIds) {
274
+ await recheck(id)
275
+ }
276
+ }
277
+ await store.claim(confirmation, context)
278
+ const result: Receipt = {
279
+ resource: resource.id,
280
+ operation: prepared.operation,
281
+ completedIds: [],
282
+ success: true,
283
+ }
284
+ try {
285
+ if (prepared.operation === 'create') {
286
+ result.completedIds.push((await resource.create(input)).id)
287
+ } else {
288
+ for (const id of selectedIds) {
289
+ await recheck(id)
290
+ if (prepared.operation === 'delete') {
291
+ await resource.delete(id)
292
+ } else {
293
+ await resource.update(id, input)
294
+ }
295
+ result.completedIds.push(id)
296
+ }
297
+ }
298
+ } catch (cause) {
299
+ result.success = false
300
+ result.error = cause instanceof Error ? cause.message : 'The action could not be completed.'
301
+ }
302
+ // An unrecorded result must stay "executing" and cannot be retried as another mutation.
303
+ await store.finish(confirmation, result, context)
304
+ return result
305
+ }
306
+
307
+ async function cancel(key: string, context: Context) {
308
+ const confirmation = await store.get(key, context)
309
+ if (confirmation.actorId !== context.actorId || confirmation.sessionId !== context.sessionId) {
310
+ throw new OperationError(403, 'This confirmation belongs to another session.')
311
+ }
312
+ if (confirmation.state === 'pending') {
313
+ await store.cancel(confirmation, context)
314
+ }
315
+ }
316
+ return { plan, execute, cancel }
317
+ }
package/core/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './types'
2
+ export { createOperationEngine } from './engine'
3
+ export { createDecisionPlanner } from './planner'
@@ -0,0 +1,67 @@
1
+ import { choice, noul } from '@typesafe-ai/sdk'
2
+ import { OperationError, OPERATIONS, type Operation, type Planner } from './types'
3
+ import type { DecisionInference } from './decisions'
4
+
5
+ export function createDecisionPlanner(
6
+ infer: DecisionInference,
7
+ extract: Planner['extract'],
8
+ ): Planner {
9
+ return {
10
+ async route(prompt, resources, context) {
11
+ const result = await infer({
12
+ state: {
13
+ request: prompt,
14
+ currentResource: context.resource ?? null,
15
+ selectedId: context.selection ?? null,
16
+ },
17
+ questions: {
18
+ resource: choice(
19
+ 'Which resource does the requested operation concern? Explicit resource names override the current page. Use the current resource only for implicit requests such as delete this or rename it. Accept synonyms and obvious spelling mistakes. Choose none for editor commands, navigation, questions, or unsupported resources.',
20
+ {
21
+ ...Object.fromEntries(
22
+ resources.map((resource) => [
23
+ resource.id,
24
+ `${resource.label}; ${resource.aliases.join(', ')}`,
25
+ ]),
26
+ ),
27
+ none: 'No available CRUD resource fits.',
28
+ },
29
+ ),
30
+ operation: choice(
31
+ 'Which one database operation is explicitly requested? A request to delete, remove or erase is delete even with typos. Updating existing fields is update. List/search/filter records is list. Read/open one identified record is read. Creating a record is create. Never interpret an edit or delete as a read-only query. Editor commands, file export, navigation and general questions are none.',
32
+ {
33
+ list: 'Find, filter, search, or list records.',
34
+ read: 'Read a single existing record.',
35
+ create: 'Create a new database record or upload a file.',
36
+ update: 'Change fields on existing records.',
37
+ delete: 'Delete existing database records.',
38
+ none: 'No CRUD operation is requested.',
39
+ },
40
+ ),
41
+ single_action: noul(
42
+ 'Does the request describe at most ONE operation on ONE resource? Updating several fields or operating on several records of one resource is one operation. A conditional workflow, a sequence of different actions, or multiple resources is not supported.',
43
+ ),
44
+ },
45
+ })
46
+ const { resource, operation, single_action: single } = result.answers
47
+ if (
48
+ resource?.type !== 'choice' ||
49
+ operation?.type !== 'choice' ||
50
+ single?.type !== 'noul' ||
51
+ (!resources.some((entry) => entry.id === resource.choice) && resource.choice !== 'none') ||
52
+ (!OPERATIONS.some((entry) => entry === operation.choice) && operation.choice !== 'none')
53
+ ) {
54
+ throw new OperationError(502, 'The provider returned an unavailable action or resource.')
55
+ }
56
+ if (single.noul < 0.8) {
57
+ throw new OperationError(422, 'Request one action on one resource at a time.')
58
+ }
59
+ return {
60
+ resource: resource.choice === 'none' ? null : resource.choice,
61
+ operation: operation.choice === 'none' ? null : (operation.choice as Operation),
62
+ confidence: Math.min(resource.confidence, operation.confidence, single.noul),
63
+ }
64
+ },
65
+ extract,
66
+ }
67
+ }
package/core/types.ts ADDED
@@ -0,0 +1,188 @@
1
+ export const OPERATIONS = ['list', 'read', 'create', 'update', 'delete'] as const
2
+ export type Operation = (typeof OPERATIONS)[number]
3
+ export type Mutation = Extract<Operation, 'create' | 'update' | 'delete'>
4
+ export type Row = { id: number; [key: string]: unknown }
5
+
6
+ export type Scalar = string | number | boolean | null
7
+ export interface Condition {
8
+ type: 'condition'
9
+ field: string
10
+ operator:
11
+ | 'eq'
12
+ | 'neq'
13
+ | 'like'
14
+ | 'not_contains'
15
+ | 'starts_with'
16
+ | 'ends_with'
17
+ | 'gt'
18
+ | 'gte'
19
+ | 'lt'
20
+ | 'lte'
21
+ | 'is_empty'
22
+ | 'is_not_empty'
23
+ | 'is_any_of'
24
+ | 'is_none_of'
25
+ value: Scalar | (string | number)[]
26
+ }
27
+
28
+ export interface Filters {
29
+ type: 'group'
30
+ logic: 'and' | 'or'
31
+ children: (Condition | Filters)[]
32
+ }
33
+
34
+ export interface Query {
35
+ page?: number
36
+ pageSize?: number
37
+ sortBy?: string
38
+ sortDir?: 'asc' | 'desc'
39
+ filters?: Filters
40
+ }
41
+
42
+ export interface Field {
43
+ key: string
44
+ label: string
45
+ kind: string
46
+ required: boolean
47
+ options?: readonly string[]
48
+ writable: boolean
49
+ }
50
+
51
+ export interface ResourceInfo {
52
+ id: string
53
+ label: string
54
+ aliases: readonly string[]
55
+ fields: readonly Field[]
56
+ operations: readonly Operation[]
57
+ /** Public fields shown in results. Large document bodies are deliberately omitted. */
58
+ displayFields: readonly string[]
59
+ createFields: readonly string[]
60
+ updateFields: readonly string[]
61
+ /** Storage creation must collect bytes through the host's existing upload flow. */
62
+ createHandoff?: string
63
+ }
64
+
65
+ export interface Resource extends ResourceInfo {
66
+ list(query: Query): Promise<{ data: Row[]; totalCount: number }>
67
+ get(id: number): Promise<Row>
68
+ create(input: Record<string, unknown>): Promise<Row>
69
+ update(id: number, input: Record<string, unknown>): Promise<Row>
70
+ delete(id: number): Promise<void>
71
+ /** Must use the host's actual access rules, including row and input-dependent rules. */
72
+ authorize(operation: Operation, id?: number, input?: Record<string, unknown>): Promise<void>
73
+ validateInput(
74
+ operation: 'create' | 'update',
75
+ input: Record<string, unknown>,
76
+ ): Record<string, unknown>
77
+ }
78
+
79
+ export interface Context {
80
+ actorId: string
81
+ sessionId: string
82
+ resource?: string
83
+ selection?: number
84
+ }
85
+
86
+ export interface Proposal {
87
+ supported: boolean
88
+ clarification: string
89
+ input: Record<string, unknown>
90
+ id: number | null
91
+ useSelection: boolean
92
+ many: boolean
93
+ filters: Filters
94
+ sortBy: string | null
95
+ sortDir: 'asc' | 'desc'
96
+ limit: number | null
97
+ }
98
+
99
+ export interface Route {
100
+ resource: string | null
101
+ operation: Operation | null
102
+ confidence: number
103
+ }
104
+
105
+ export interface Planner {
106
+ route(prompt: string, resources: readonly ResourceInfo[], context: Context): Promise<Route>
107
+ extract(
108
+ prompt: string,
109
+ resource: ResourceInfo,
110
+ operation: Operation,
111
+ context: Context,
112
+ ): Promise<Proposal>
113
+ }
114
+
115
+ export interface Target {
116
+ id: number
117
+ fingerprint: string
118
+ }
119
+
120
+ export interface PreparedMutation {
121
+ resource: string
122
+ operation: Mutation
123
+ input: Record<string, unknown>
124
+ targets: Target[]
125
+ many: boolean
126
+ }
127
+
128
+ export interface Receipt {
129
+ resource: string
130
+ operation: Mutation
131
+ completedIds: number[]
132
+ success: boolean
133
+ error?: string
134
+ }
135
+
136
+ export interface Confirmation {
137
+ key: string
138
+ actorId: string
139
+ sessionId: string
140
+ expiresAt: number
141
+ state: 'pending' | 'executing' | 'completed' | 'failed' | 'cancelled'
142
+ plan: PreparedMutation
143
+ result?: Receipt
144
+ revision: number
145
+ }
146
+
147
+ export interface ConfirmationStore {
148
+ create(plan: PreparedMutation, context: Context): Promise<Confirmation>
149
+ get(key: string, context: Context): Promise<Confirmation>
150
+ /** Atomic compare-and-swap. A competing claimant MUST fail before performing a write. */
151
+ claim(confirmation: Confirmation, context: Context): Promise<void>
152
+ finish(confirmation: Confirmation, result: Receipt, context: Context): Promise<void>
153
+ cancel(confirmation: Confirmation, context: Context): Promise<void>
154
+ }
155
+
156
+ export type PlanResult =
157
+ | { kind: 'fallback' }
158
+ | { kind: 'clarification'; message: string }
159
+ | { kind: 'handoff'; resource: string; action: string }
160
+ | {
161
+ kind: 'result'
162
+ resource: string
163
+ label: string
164
+ fields: readonly Field[]
165
+ records: Row[]
166
+ totalCount: number
167
+ }
168
+ | {
169
+ kind: 'confirmation'
170
+ key: string
171
+ resource: string
172
+ label: string
173
+ operation: Mutation
174
+ fields: readonly Field[]
175
+ records: Row[]
176
+ input: Record<string, unknown>
177
+ many: boolean
178
+ expiresAt: number
179
+ }
180
+
181
+ export class OperationError extends Error {
182
+ constructor(
183
+ public readonly status: number,
184
+ message: string,
185
+ ) {
186
+ super(message)
187
+ }
188
+ }