@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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createError } from 'h3'
|
|
2
|
+
import { choice, type EntryType, type Questions } from '@typesafe-ai/sdk'
|
|
3
|
+
import type { AgentToolCall, AgentToolName, ToolSpec } from '../../shared/agent-tools'
|
|
4
|
+
import type { ModelView } from '@nuxtagentic/data/runtime/shared/model-views'
|
|
5
|
+
import type { PromptConfig } from '../../shared/prompt-config'
|
|
6
|
+
import { compileModelQuery, type ModelQueryPlan } from './model-query'
|
|
7
|
+
import { prepareCandidates } from './prompt-candidates'
|
|
8
|
+
import { prepareToolArguments } from './jev-tool-plan'
|
|
9
|
+
import { createAnswerReader, summarizeDecisions, NO_CHOICE } from './jev-answers'
|
|
10
|
+
import type { JevInference } from './jev-inference'
|
|
11
|
+
|
|
12
|
+
interface DispatcherConfig {
|
|
13
|
+
tools: Record<AgentToolName, ToolSpec>
|
|
14
|
+
views: readonly ModelView[]
|
|
15
|
+
query: PromptConfig
|
|
16
|
+
}
|
|
17
|
+
interface PromptContext {
|
|
18
|
+
model?: string
|
|
19
|
+
resource?: string
|
|
20
|
+
}
|
|
21
|
+
const ROUTE = '__tool__'
|
|
22
|
+
const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
23
|
+
|
|
24
|
+
export function createDispatcher({ tools, views, query }: DispatcherConfig) {
|
|
25
|
+
const models = views.map((view) => ({
|
|
26
|
+
...compileModelQuery(view, query),
|
|
27
|
+
route: `model:${view.id}`,
|
|
28
|
+
mention: new RegExp(
|
|
29
|
+
`\\b(?:${[view.id, view.model, ...view.aliases].map(escapeRegex).join('|')})\\b`,
|
|
30
|
+
'i',
|
|
31
|
+
),
|
|
32
|
+
}))
|
|
33
|
+
const modelRoutes = new Map(models.map((model) => [model.route, model]))
|
|
34
|
+
const modelIds = new Map(models.map((model) => [model.view.id, model]))
|
|
35
|
+
const toolEntries = Object.entries(tools).filter(([name]) => name !== 'query_models') as [
|
|
36
|
+
AgentToolName,
|
|
37
|
+
ToolSpec,
|
|
38
|
+
][]
|
|
39
|
+
const routeQuestion = choice(
|
|
40
|
+
'What is the request asking the app to do? Recognize SQL, shorthand, collection names, synonyms and obvious spelling mistakes. Choose a model only for a read-only request about that table.',
|
|
41
|
+
{
|
|
42
|
+
...Object.fromEntries(toolEntries.map(([name, tool]) => [name, tool.description])),
|
|
43
|
+
...Object.fromEntries(
|
|
44
|
+
models.map((model) => [
|
|
45
|
+
model.route,
|
|
46
|
+
{
|
|
47
|
+
action: tools.query_models?.description ?? 'Search this collection',
|
|
48
|
+
collection: model.view.label,
|
|
49
|
+
aliases: [...model.view.aliases],
|
|
50
|
+
},
|
|
51
|
+
]),
|
|
52
|
+
),
|
|
53
|
+
[NO_CHOICE]:
|
|
54
|
+
'A greeting, a question about the app, or anything outside available capabilities',
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return async function dispatchPrompt(
|
|
59
|
+
prompt: string,
|
|
60
|
+
infer: JevInference,
|
|
61
|
+
input: PromptContext = {},
|
|
62
|
+
): Promise<AgentToolCall> {
|
|
63
|
+
const context = prepareCandidates(prompt, query)
|
|
64
|
+
const model = input.model ?? query.model
|
|
65
|
+
const state = (schemas: Record<string, EntryType>): EntryType => ({
|
|
66
|
+
request: prompt,
|
|
67
|
+
interpretationRules: query.instructions,
|
|
68
|
+
referenceDate: context.now.toISOString(),
|
|
69
|
+
models: schemas,
|
|
70
|
+
})
|
|
71
|
+
if (input.resource) {
|
|
72
|
+
const target = modelIds.get(input.resource)
|
|
73
|
+
if (!target) {
|
|
74
|
+
throw createError({ statusCode: 422, statusMessage: 'Choose an enabled model to search.' })
|
|
75
|
+
}
|
|
76
|
+
const plan = target.prepare(context)
|
|
77
|
+
return plan.decode(
|
|
78
|
+
await infer(
|
|
79
|
+
{ model, state: state({ [target.view.id]: plan.schema }), questions: plan.questions },
|
|
80
|
+
`model:${target.view.id}`,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const toolPlans = new Map(
|
|
86
|
+
toolEntries.map(([name, spec]) => [name, prepareToolArguments(name, spec, context.spans)]),
|
|
87
|
+
)
|
|
88
|
+
let questions: Questions = { [ROUTE]: routeQuestion }
|
|
89
|
+
for (const plan of toolPlans.values()) {
|
|
90
|
+
Object.assign(questions, plan.questions)
|
|
91
|
+
}
|
|
92
|
+
const inline = new Map<string, ModelQueryPlan>()
|
|
93
|
+
let schemas: Record<string, EntryType> = {}
|
|
94
|
+
const mentioned = models.filter((entry) => entry.mention.test(prompt))
|
|
95
|
+
const shortlist = (mentioned.length ? mentioned : models).slice(
|
|
96
|
+
0,
|
|
97
|
+
query.routing.maxInlineModels,
|
|
98
|
+
)
|
|
99
|
+
for (const entry of shortlist) {
|
|
100
|
+
const plan = entry.prepare(context, `${entry.route}:`)
|
|
101
|
+
const nextQuestions = { ...questions, ...plan.questions }
|
|
102
|
+
const nextSchemas = { ...schemas, [entry.view.id]: plan.schema }
|
|
103
|
+
if (
|
|
104
|
+
Object.keys(nextQuestions).length > query.routing.maxInlineQuestions ||
|
|
105
|
+
new TextEncoder().encode(
|
|
106
|
+
JSON.stringify({ model, state: state(nextSchemas), questions: nextQuestions }),
|
|
107
|
+
).byteLength > query.routing.maxInlineBytes
|
|
108
|
+
) {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
questions = nextQuestions
|
|
112
|
+
schemas = nextSchemas
|
|
113
|
+
inline.set(entry.route, plan)
|
|
114
|
+
}
|
|
115
|
+
const result = await infer(
|
|
116
|
+
{
|
|
117
|
+
model,
|
|
118
|
+
questions,
|
|
119
|
+
state: inline.size ? state(schemas) : { request: prompt },
|
|
120
|
+
},
|
|
121
|
+
inline.size ? 'route-and-extract' : 'route',
|
|
122
|
+
)
|
|
123
|
+
const reader = createAnswerReader(result)
|
|
124
|
+
const route = reader.pick(ROUTE)
|
|
125
|
+
const target = modelRoutes.get(route)
|
|
126
|
+
if (target) {
|
|
127
|
+
const plan = inline.get(route)
|
|
128
|
+
if (plan) {
|
|
129
|
+
return plan.decode(result, reader.decisions)
|
|
130
|
+
}
|
|
131
|
+
const selected = target.prepare(context)
|
|
132
|
+
const extraction = await infer(
|
|
133
|
+
{
|
|
134
|
+
model: result.model,
|
|
135
|
+
state: state({ [target.view.id]: selected.schema }),
|
|
136
|
+
questions: selected.questions,
|
|
137
|
+
},
|
|
138
|
+
`model:${target.view.id}`,
|
|
139
|
+
)
|
|
140
|
+
return selected.decode(extraction, reader.decisions)
|
|
141
|
+
}
|
|
142
|
+
const toolPlan = toolPlans.get(route as AgentToolName)
|
|
143
|
+
const argumentsRead = toolPlan?.decode(reader) ?? {}
|
|
144
|
+
return {
|
|
145
|
+
tool: toolPlan ? (route as AgentToolName) : null,
|
|
146
|
+
args: Object.fromEntries(
|
|
147
|
+
Object.entries(argumentsRead).flatMap(([arg, value]) =>
|
|
148
|
+
value.omitted ? [] : [[arg, value.value!]],
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
arguments: reader.decisions,
|
|
152
|
+
...summarizeDecisions(reader.decisions),
|
|
153
|
+
model: result.model,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createError, type H3Event } from 'h3'
|
|
2
|
+
import type { SystemOneRequest } from '@typesafe-ai/sdk'
|
|
3
|
+
|
|
4
|
+
import type { InferencePolicy } from '../../shared/prompt-config'
|
|
5
|
+
import type { JevResult } from './jev-answers'
|
|
6
|
+
import { useDecisionProvider } from './decision-provider'
|
|
7
|
+
|
|
8
|
+
export type JevInference = (request: SystemOneRequest, stage: string) => Promise<JevResult>
|
|
9
|
+
|
|
10
|
+
export function useJevInference(event: H3Event, policy: InferencePolicy, logMetrics: boolean) {
|
|
11
|
+
const controller = new AbortController()
|
|
12
|
+
const requestStarted = performance.now()
|
|
13
|
+
let attempts = 0
|
|
14
|
+
const provider = useDecisionProvider(event, policy, () => {
|
|
15
|
+
attempts++
|
|
16
|
+
})
|
|
17
|
+
const timer = setTimeout(
|
|
18
|
+
() => controller.abort(new DOMException('Inference deadline exceeded', 'TimeoutError')),
|
|
19
|
+
policy.deadlineMs,
|
|
20
|
+
)
|
|
21
|
+
const abort = () => controller.abort()
|
|
22
|
+
const disconnect = () => {
|
|
23
|
+
if (!event.node.res.writableEnded) {
|
|
24
|
+
abort()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const requestSignal = event.web?.request?.signal
|
|
28
|
+
requestSignal?.addEventListener('abort', abort, { once: true })
|
|
29
|
+
event.node.res.once('close', disconnect)
|
|
30
|
+
if (requestSignal?.aborted || event.node.req.aborted) {
|
|
31
|
+
abort()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const infer: JevInference = async (request, stage) => {
|
|
35
|
+
const started = performance.now()
|
|
36
|
+
const previousAttempts = attempts
|
|
37
|
+
let usage: JevResult['usage'] | undefined
|
|
38
|
+
let model = provider.id === 'openAI' ? provider.model : (request.model ?? provider.model)
|
|
39
|
+
let outcome = 'success'
|
|
40
|
+
try {
|
|
41
|
+
const result = await provider.infer(request, { signal: controller.signal })
|
|
42
|
+
usage = result.usage
|
|
43
|
+
model = result.model
|
|
44
|
+
return result
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
outcome = 'error'
|
|
47
|
+
if (controller.signal.aborted) {
|
|
48
|
+
const timedOut = controller.signal.reason?.name === 'TimeoutError'
|
|
49
|
+
throw createError({
|
|
50
|
+
statusCode: timedOut ? 504 : 499,
|
|
51
|
+
statusMessage: timedOut
|
|
52
|
+
? `${provider.name} took too long. Try a simpler request or try again.`
|
|
53
|
+
: 'The request was cancelled.',
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
throw cause
|
|
57
|
+
} finally {
|
|
58
|
+
if (logMetrics) {
|
|
59
|
+
console.info('[decisions]', {
|
|
60
|
+
provider: provider.id,
|
|
61
|
+
stage,
|
|
62
|
+
model,
|
|
63
|
+
outcome,
|
|
64
|
+
elapsedMs: Math.round(performance.now() - started),
|
|
65
|
+
requestElapsedMs: Math.round(performance.now() - requestStarted),
|
|
66
|
+
retries: Math.max(0, attempts - previousAttempts - 1),
|
|
67
|
+
questions: Object.keys(request.questions).length,
|
|
68
|
+
options: Object.values(request.questions).reduce(
|
|
69
|
+
(count, question) =>
|
|
70
|
+
count + (question.type === 'choice' ? Object.keys(question.criteria).length : 0),
|
|
71
|
+
0,
|
|
72
|
+
),
|
|
73
|
+
payloadBytes: new TextEncoder().encode(JSON.stringify(request)).byteLength,
|
|
74
|
+
...usage,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
infer,
|
|
81
|
+
close() {
|
|
82
|
+
clearTimeout(timer)
|
|
83
|
+
requestSignal?.removeEventListener('abort', abort)
|
|
84
|
+
event.node.res.off('close', disconnect)
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { choice, noul, type Questions } from '@typesafe-ai/sdk'
|
|
2
|
+
import type { ToolSpec, ToolCallArgument } from '../../shared/agent-tools'
|
|
3
|
+
import {
|
|
4
|
+
NOT_MENTIONED,
|
|
5
|
+
VALUE_UNAVAILABLE,
|
|
6
|
+
throwUnrepresentableQuery,
|
|
7
|
+
type createAnswerReader,
|
|
8
|
+
} from './jev-answers'
|
|
9
|
+
|
|
10
|
+
type AnswerReader = ReturnType<typeof createAnswerReader>
|
|
11
|
+
|
|
12
|
+
export function prepareToolArguments(tool: string, spec: ToolSpec, candidates: string[]) {
|
|
13
|
+
const questions: Questions = {}
|
|
14
|
+
const readers: Record<string, (reader: AnswerReader) => ToolCallArgument> = {}
|
|
15
|
+
|
|
16
|
+
for (const [arg, argument] of Object.entries(spec.args)) {
|
|
17
|
+
const key = `${tool}.${arg}`
|
|
18
|
+
if (argument.kind === 'flag') {
|
|
19
|
+
questions[key] = noul(argument.question)
|
|
20
|
+
readers[arg] = (reader) => {
|
|
21
|
+
reader.flag(key)
|
|
22
|
+
const decision = reader.decisions[key]!
|
|
23
|
+
decision.omitted = false
|
|
24
|
+
return decision
|
|
25
|
+
}
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const options =
|
|
30
|
+
argument.kind === 'span'
|
|
31
|
+
? Object.fromEntries(candidates.map((value, index) => [`v${index}`, value]))
|
|
32
|
+
: argument.options
|
|
33
|
+
questions[key] = choice(
|
|
34
|
+
{
|
|
35
|
+
question: argument.question,
|
|
36
|
+
...(argument.stated ? { presence: argument.stated } : {}),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
...options,
|
|
40
|
+
...(argument.stated ? { [NOT_MENTIONED]: 'Not mentioned in the request' } : {}),
|
|
41
|
+
...(argument.kind === 'span'
|
|
42
|
+
? { [VALUE_UNAVAILABLE]: 'Mentioned, but the complete value is not a candidate' }
|
|
43
|
+
: {}),
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
readers[arg] = (reader) => {
|
|
47
|
+
const picked = reader.pick(key)
|
|
48
|
+
if (picked === VALUE_UNAVAILABLE) {
|
|
49
|
+
return throwUnrepresentableQuery()
|
|
50
|
+
}
|
|
51
|
+
const decision = reader.decisions[key]!
|
|
52
|
+
if (!decision.omitted && argument.kind === 'span') {
|
|
53
|
+
decision.value = candidates[Number(picked.slice(1))]!
|
|
54
|
+
}
|
|
55
|
+
return decision
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
questions,
|
|
61
|
+
decode: (reader: AnswerReader) =>
|
|
62
|
+
Object.fromEntries(Object.entries(readers).map(([arg, read]) => [arg, read(reader)])),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ModelView } from '@nuxtagentic/data/runtime/shared/model-views'
|
|
2
|
+
import {
|
|
3
|
+
createDatePeriodCondition,
|
|
4
|
+
type Comparison,
|
|
5
|
+
type Scalar,
|
|
6
|
+
type Where,
|
|
7
|
+
} from '@nuxtagentic/data/runtime/shared/resource-query'
|
|
8
|
+
import { throwUnrepresentableQuery } from './jev-answers'
|
|
9
|
+
import type { ValueCandidate } from './prompt-candidates'
|
|
10
|
+
|
|
11
|
+
export function combineConditions(nodes: Where[], logic: 'and' | 'or'): Where | null {
|
|
12
|
+
return nodes.reduce<Where | null>(
|
|
13
|
+
(left, right) => (left ? { type: logic, left, right } : right),
|
|
14
|
+
null,
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createModelCondition(
|
|
19
|
+
view: ModelView,
|
|
20
|
+
field: string,
|
|
21
|
+
operator: string,
|
|
22
|
+
candidate: ValueCandidate,
|
|
23
|
+
): Where {
|
|
24
|
+
const value = candidate.value
|
|
25
|
+
const compare = (comparison: Comparison, value: Scalar): Where => ({
|
|
26
|
+
type: 'cond',
|
|
27
|
+
column: field,
|
|
28
|
+
op: 'cmp',
|
|
29
|
+
comparison,
|
|
30
|
+
value,
|
|
31
|
+
})
|
|
32
|
+
if (operator === 'period' || operator === 'after_period') {
|
|
33
|
+
if (!candidate.end || typeof value !== 'string') {
|
|
34
|
+
return throwUnrepresentableQuery()
|
|
35
|
+
}
|
|
36
|
+
return createDatePeriodCondition(field, operator === 'period' ? '=' : '>', value, candidate.end)
|
|
37
|
+
}
|
|
38
|
+
if (['=', '!=', '>', '>=', '<', '<='].includes(operator)) {
|
|
39
|
+
if (operator === '=' && value === false && view.query?.nullAsFalse?.includes(field)) {
|
|
40
|
+
return { type: 'or', left: compare('=', false), right: compare('=', null) }
|
|
41
|
+
}
|
|
42
|
+
return candidate.end && typeof value === 'string'
|
|
43
|
+
? createDatePeriodCondition(field, operator as Comparison, value, candidate.end)
|
|
44
|
+
: compare(operator as Comparison, value)
|
|
45
|
+
}
|
|
46
|
+
if (typeof value !== 'string') {
|
|
47
|
+
return throwUnrepresentableQuery()
|
|
48
|
+
}
|
|
49
|
+
const prefix = operator === 'starts_with' ? '' : '%'
|
|
50
|
+
const suffix = operator === 'ends_with' ? '' : '%'
|
|
51
|
+
const pattern = `${prefix}${value}${suffix}`
|
|
52
|
+
return {
|
|
53
|
+
type: 'cond',
|
|
54
|
+
column: field,
|
|
55
|
+
op: 'like',
|
|
56
|
+
isNegated: operator === 'not_contains',
|
|
57
|
+
value: pattern,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { choice, noul, type EntryType, type Questions } from '@typesafe-ai/sdk'
|
|
2
|
+
import type { ModelField, ModelView } from '@nuxtagentic/data/runtime/shared/model-views'
|
|
3
|
+
import type { PromptConfig } from '../../shared/prompt-config'
|
|
4
|
+
import type { AgentToolCall } from '../../shared/agent-tools'
|
|
5
|
+
import {
|
|
6
|
+
toResourceTableQuery,
|
|
7
|
+
type ResourceQuery,
|
|
8
|
+
type Where,
|
|
9
|
+
} from '@nuxtagentic/data/runtime/shared/resource-query'
|
|
10
|
+
import {
|
|
11
|
+
createAnswerReader,
|
|
12
|
+
summarizeDecisions,
|
|
13
|
+
throwUnrepresentableQuery,
|
|
14
|
+
NO_CHOICE,
|
|
15
|
+
NOT_MENTIONED,
|
|
16
|
+
VALUE_UNAVAILABLE,
|
|
17
|
+
type Decisions,
|
|
18
|
+
type JevResult,
|
|
19
|
+
} from './jev-answers'
|
|
20
|
+
import {
|
|
21
|
+
findFieldCandidates,
|
|
22
|
+
type PromptCandidates,
|
|
23
|
+
type ValueCandidate,
|
|
24
|
+
} from './prompt-candidates'
|
|
25
|
+
import { combineConditions, createModelCondition } from './model-query-condition'
|
|
26
|
+
|
|
27
|
+
export interface ModelQueryPlan {
|
|
28
|
+
schema: EntryType
|
|
29
|
+
questions: Questions
|
|
30
|
+
decode: (result: JevResult, routing?: Decisions) => AgentToolCall
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const logic = { and: 'All constraints must hold', or: 'Any constraint may hold' }
|
|
34
|
+
|
|
35
|
+
function fieldOperators(field: ModelField) {
|
|
36
|
+
const operators: Record<string, string> = {
|
|
37
|
+
'=': 'equals / is / implicit equality / one of several allowed values',
|
|
38
|
+
'!=': 'does not equal / is not / excluding a value',
|
|
39
|
+
}
|
|
40
|
+
if (['number', 'float', 'relation', 'date', 'datetime'].includes(field.kind)) {
|
|
41
|
+
Object.assign(operators, {
|
|
42
|
+
'>': 'greater than / after',
|
|
43
|
+
'>=': 'at least / since',
|
|
44
|
+
'<': 'less than / before',
|
|
45
|
+
'<=': 'at most / on or before',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
if (field.kind === 'date' || field.kind === 'datetime') {
|
|
49
|
+
Object.assign(operators, {
|
|
50
|
+
period: 'during a named period or within the last/past N units',
|
|
51
|
+
after_period: 'after a named period ends',
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
if (['text', 'textarea', 'email', 'url'].includes(field.kind)) {
|
|
55
|
+
Object.assign(operators, {
|
|
56
|
+
contains: 'contains text',
|
|
57
|
+
not_contains: 'does not contain text',
|
|
58
|
+
starts_with: 'starts with text',
|
|
59
|
+
ends_with: 'ends with text',
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
return operators
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function compileModelQuery(view: ModelView, config: PromptConfig) {
|
|
66
|
+
const fieldMap = new Map(view.fields.map((field) => [field.key, field]))
|
|
67
|
+
const constraintLimit = (key: string) =>
|
|
68
|
+
view.prompt?.maxConstraints?.[key] ?? config.maxConstraintsPerField
|
|
69
|
+
const fields = view.filter.map((key) => {
|
|
70
|
+
const field = fieldMap.get(key)!
|
|
71
|
+
return { field, operators: fieldOperators(field), limit: constraintLimit(key) }
|
|
72
|
+
})
|
|
73
|
+
const schema: EntryType = {
|
|
74
|
+
name: view.model,
|
|
75
|
+
label: view.label,
|
|
76
|
+
aliases: [...view.aliases],
|
|
77
|
+
instructions: view.prompt?.instructions ?? '',
|
|
78
|
+
filter: [...view.filter],
|
|
79
|
+
search: [...view.search],
|
|
80
|
+
columns: [...view.columns],
|
|
81
|
+
sort: [...view.sort],
|
|
82
|
+
fields: view.fields.map((field) => ({
|
|
83
|
+
key: field.key,
|
|
84
|
+
label: field.label,
|
|
85
|
+
kind: field.kind,
|
|
86
|
+
options: [...(field.options ?? [])],
|
|
87
|
+
meaning: view.prompt?.fields?.[field.key] ?? '',
|
|
88
|
+
maxConstraints: constraintLimit(field.key),
|
|
89
|
+
})),
|
|
90
|
+
}
|
|
91
|
+
const sortOptions = Object.fromEntries(
|
|
92
|
+
view.sort.map((key) => [key, fieldMap.get(key)?.label ?? key]),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
function prepare(context: PromptCandidates, prefix = ''): ModelQueryPlan {
|
|
96
|
+
const candidates: Record<string, ValueCandidate[]> = {}
|
|
97
|
+
const instructions = (question: string, field?: string, constraint?: number): EntryType => ({
|
|
98
|
+
question,
|
|
99
|
+
model: view.id,
|
|
100
|
+
...(field ? { field } : {}),
|
|
101
|
+
...(constraint ? { constraint } : {}),
|
|
102
|
+
})
|
|
103
|
+
const questions: Questions = {
|
|
104
|
+
supported: noul(
|
|
105
|
+
instructions(
|
|
106
|
+
'Can the WHOLE read-only request be represented by this model, its allowed fields and configured per-field constraint limits, AND/OR between fields and AND/OR within each field? Use interpretation rules and field meanings. Shorthand, synonyms, obvious typos and generic text search are supported. Modifications, unavailable properties, unsupported calculations and complex mixed grouping are not. Never ignore an unsupported part.',
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
logic: choice(instructions('How are constraints on different properties combined?'), logic),
|
|
110
|
+
search: choice(
|
|
111
|
+
instructions(
|
|
112
|
+
'Which candidate is a general search over configured search fields? Use none for explicit named-field filters or no search.',
|
|
113
|
+
),
|
|
114
|
+
{
|
|
115
|
+
...Object.fromEntries(context.text.map((value, index) => [`s${index}`, value])),
|
|
116
|
+
[NO_CHOICE]: 'No general search requested',
|
|
117
|
+
[VALUE_UNAVAILABLE]: 'A general search is requested but its complete text is unavailable',
|
|
118
|
+
},
|
|
119
|
+
),
|
|
120
|
+
sort: choice(
|
|
121
|
+
instructions(
|
|
122
|
+
'Which property is explicitly requested for ordering? Newest/oldest means createdAt unless another date is named.',
|
|
123
|
+
),
|
|
124
|
+
{
|
|
125
|
+
...sortOptions,
|
|
126
|
+
[NO_CHOICE]: 'No ordering requested',
|
|
127
|
+
[VALUE_UNAVAILABLE]: 'Ordering is requested on an unavailable property',
|
|
128
|
+
},
|
|
129
|
+
),
|
|
130
|
+
direction: choice(instructions('Which order direction is requested?'), {
|
|
131
|
+
asc: 'ascending / A-Z / oldest / smallest',
|
|
132
|
+
desc: 'descending / newest / largest',
|
|
133
|
+
}),
|
|
134
|
+
limit: choice(
|
|
135
|
+
instructions(
|
|
136
|
+
'Which number is explicitly the maximum result count? Dates and filter thresholds are not result limits.',
|
|
137
|
+
),
|
|
138
|
+
{
|
|
139
|
+
...Object.fromEntries(context.limits.map((value) => [value, value])),
|
|
140
|
+
[NO_CHOICE]: 'No result limit requested',
|
|
141
|
+
[VALUE_UNAVAILABLE]: 'A result limit is requested but its number is unavailable',
|
|
142
|
+
},
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
for (const key of view.columns) {
|
|
146
|
+
questions[`column.${key}`] = noul(
|
|
147
|
+
instructions(
|
|
148
|
+
'Is this column explicitly requested for display? A filter or sort mention does not count.',
|
|
149
|
+
key,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
for (const { field, operators, limit } of fields) {
|
|
154
|
+
const values = findFieldCandidates(
|
|
155
|
+
context,
|
|
156
|
+
field,
|
|
157
|
+
view.prompt?.values?.[field.key],
|
|
158
|
+
view.query?.units?.[field.key],
|
|
159
|
+
)
|
|
160
|
+
candidates[field.key] = values
|
|
161
|
+
const options = {
|
|
162
|
+
...Object.fromEntries(values.map((candidate, index) => [`v${index}`, candidate.label])),
|
|
163
|
+
[NOT_MENTIONED]: 'This numbered constraint is not mentioned',
|
|
164
|
+
[VALUE_UNAVAILABLE]: 'This constraint is mentioned but its complete value is unavailable',
|
|
165
|
+
}
|
|
166
|
+
questions[`logic.${field.key}`] = choice(
|
|
167
|
+
instructions(
|
|
168
|
+
'How are multiple constraints on this field combined? Ranges and exclusions use AND; one-of alternatives use OR.',
|
|
169
|
+
field.key,
|
|
170
|
+
),
|
|
171
|
+
logic,
|
|
172
|
+
)
|
|
173
|
+
for (let slot = 0; slot < limit; slot++) {
|
|
174
|
+
const key = `${field.key}.${slot}`
|
|
175
|
+
questions[`operator.${key}`] = choice(
|
|
176
|
+
instructions(
|
|
177
|
+
'What is the operator of this distinct constraint, numbered by appearance? Resolve negation into the operator or boolean value, never both. BETWEEN uses >= then <=.',
|
|
178
|
+
field.key,
|
|
179
|
+
slot + 1,
|
|
180
|
+
),
|
|
181
|
+
operators,
|
|
182
|
+
)
|
|
183
|
+
questions[`value.${key}`] = choice(
|
|
184
|
+
instructions(
|
|
185
|
+
'Select the complete value of this numbered constraint using interpretation rules and field meanings. Include multi-word names. Choose absent if this constraint is not mentioned, or unavailable if mentioned but its complete value is not a candidate. For negated boolean adjectives select the intended truth value with equality.',
|
|
186
|
+
field.key,
|
|
187
|
+
slot + 1,
|
|
188
|
+
),
|
|
189
|
+
options,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function decode(result: JevResult, routing: Decisions = {}): AgentToolCall {
|
|
195
|
+
const { pick, flag, decisions } = createAnswerReader(result, routing, prefix)
|
|
196
|
+
if (!flag('supported')) {
|
|
197
|
+
return throwUnrepresentableQuery()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const fieldNodes: Where[] = []
|
|
201
|
+
for (const { field, limit } of fields) {
|
|
202
|
+
const nodes: Where[] = []
|
|
203
|
+
let absent = false
|
|
204
|
+
for (let slot = 0; slot < limit; slot++) {
|
|
205
|
+
const key = `${field.key}.${slot}`
|
|
206
|
+
const valueKey = pick(`value.${key}`)
|
|
207
|
+
if (valueKey === NOT_MENTIONED) {
|
|
208
|
+
absent = true
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (absent || valueKey === VALUE_UNAVAILABLE) {
|
|
212
|
+
return throwUnrepresentableQuery()
|
|
213
|
+
}
|
|
214
|
+
const candidate = candidates[field.key]![Number(valueKey.slice(1))]
|
|
215
|
+
if (!candidate) {
|
|
216
|
+
return throwUnrepresentableQuery()
|
|
217
|
+
}
|
|
218
|
+
nodes.push(createModelCondition(view, field.key, pick(`operator.${key}`), candidate))
|
|
219
|
+
}
|
|
220
|
+
if (nodes.length) {
|
|
221
|
+
const logic = nodes.length > 1 ? (pick(`logic.${field.key}`) as 'and' | 'or') : 'and'
|
|
222
|
+
fieldNodes.push(combineConditions(nodes, logic)!)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const searchKey = pick('search')
|
|
226
|
+
if (searchKey !== NO_CHOICE) {
|
|
227
|
+
const value = context.text[Number(searchKey.slice(1))]
|
|
228
|
+
if (!value || !view.search.length || searchKey === VALUE_UNAVAILABLE) {
|
|
229
|
+
return throwUnrepresentableQuery()
|
|
230
|
+
}
|
|
231
|
+
fieldNodes.push({ type: 'search', value })
|
|
232
|
+
}
|
|
233
|
+
const columns = view.columns.filter((key) => flag(`column.${key}`))
|
|
234
|
+
const sort = pick('sort')
|
|
235
|
+
const limit = pick('limit')
|
|
236
|
+
if (sort === VALUE_UNAVAILABLE || limit === VALUE_UNAVAILABLE) {
|
|
237
|
+
return throwUnrepresentableQuery()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const query: ResourceQuery = {
|
|
241
|
+
columns: columns.length ? columns : null,
|
|
242
|
+
where: combineConditions(
|
|
243
|
+
fieldNodes,
|
|
244
|
+
fieldNodes.length > 1 ? (pick('logic') as 'and' | 'or') : 'and',
|
|
245
|
+
),
|
|
246
|
+
order:
|
|
247
|
+
sort === NO_CHOICE
|
|
248
|
+
? null
|
|
249
|
+
: { column: sort, direction: pick('direction') === 'asc' ? 1 : -1 },
|
|
250
|
+
limit: limit === NO_CHOICE ? null : Number(limit),
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
toResourceTableQuery(query, view)
|
|
254
|
+
} catch {
|
|
255
|
+
return throwUnrepresentableQuery()
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
tool: 'query_models',
|
|
259
|
+
args: {},
|
|
260
|
+
arguments: decisions,
|
|
261
|
+
...summarizeDecisions(decisions),
|
|
262
|
+
model: result.model,
|
|
263
|
+
resource: { id: view.id, query },
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
schema,
|
|
268
|
+
questions: Object.fromEntries(
|
|
269
|
+
Object.entries(questions).map(([key, question]) => [prefix + key, question]),
|
|
270
|
+
),
|
|
271
|
+
decode,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { view, prepare }
|
|
275
|
+
}
|