@nuxtagentic/prompt 0.0.0-stage → 0.1.1
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.
- package/LICENSE.md +9 -0
- package/README.md +91 -2
- package/cloudconfig.ts +165 -0
- package/config.ts +33 -0
- package/core/decisions.ts +155 -0
- package/core/engine.ts +317 -0
- package/core/index.ts +3 -0
- package/core/planner.ts +67 -0
- package/core/types.ts +188 -0
- package/core/validation.ts +187 -0
- package/docs/search.md +69 -0
- package/module.ts +26 -0
- package/package.json +56 -3
- package/runtime/server/api/agent/cancel.post.ts +17 -0
- package/runtime/server/api/agent/execute.post.ts +26 -0
- package/runtime/server/api/agent/plan.post.ts +37 -0
- package/runtime/server/api/agent.post.ts +60 -0
- package/runtime/server/confirmations.ts +104 -0
- package/runtime/server/utils/agent-operations.ts +112 -0
- package/runtime/server/utils/agent-session.ts +14 -0
- package/runtime/server/utils/decision-arguments.ts +363 -0
- package/runtime/server/utils/decision-provider.ts +141 -0
- package/runtime/server/utils/jev-answers.ts +55 -0
- package/runtime/server/utils/jev-dispatch.ts +156 -0
- package/runtime/server/utils/jev-inference.ts +87 -0
- package/runtime/server/utils/jev-tool-plan.ts +64 -0
- package/runtime/server/utils/model-query-condition.ts +59 -0
- package/runtime/server/utils/model-query.ts +275 -0
- package/runtime/server/utils/prompt-candidates.ts +165 -0
- package/runtime/server/utils/prompt-dates.ts +83 -0
- package/runtime/shared/agent-tools.ts +50 -0
- package/runtime/shared/agent.ts +5 -0
- package/runtime/shared/decision-provider.ts +1 -0
- package/runtime/shared/prompt-config.ts +27 -0
- package/schema.ts +69 -0
- 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
package/core/planner.ts
ADDED
|
@@ -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
|
+
}
|