@json-layout/core 2.8.2 → 2.9.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/package.json +4 -2
- package/src/compile/index.js +3 -1
- package/src/compile/skeleton-node.js +46 -5
- package/src/compile/types.ts +1 -0
- package/src/compile/utils/resolve-refs.js +5 -8
- package/src/compile/utils/x-i18n.js +15 -6
- package/src/state/index.js +18 -1
- package/src/state/state-node.js +11 -2
- package/src/state/utils/urls.js +2 -0
- package/src/utils/json-pointer.js +29 -0
- package/src/webmcp/README.md +144 -0
- package/src/webmcp/index.js +88 -82
- package/src/webmcp/project.js +542 -111
- package/src/webmcp/resolve.js +37 -1
- package/src/webmcp/schema.js +169 -0
- package/src/webmcp/suggestions-store.js +121 -0
- package/src/webmcp/tools/describe-state.js +20 -64
- package/src/webmcp/tools/edit-array.js +51 -28
- package/src/webmcp/tools/fill-form-skill.js +17 -41
- package/src/webmcp/tools/get-data.js +66 -13
- package/src/webmcp/tools/get-field-suggestions.js +12 -23
- package/src/webmcp/tools/set-data.js +79 -28
- package/src/webmcp/tools/set-field-value.js +88 -39
- package/src/webmcp/variants-memo.js +53 -0
- package/types/compile/index.d.ts.map +1 -1
- package/types/compile/skeleton-node.d.ts +9 -2
- package/types/compile/skeleton-node.d.ts.map +1 -1
- package/types/compile/types.d.ts +1 -0
- package/types/compile/types.d.ts.map +1 -1
- package/types/compile/utils/resolve-refs.d.ts.map +1 -1
- package/types/compile/utils/x-i18n.d.ts +1 -1
- package/types/compile/utils/x-i18n.d.ts.map +1 -1
- package/types/state/index.d.ts +10 -0
- package/types/state/index.d.ts.map +1 -1
- package/types/state/state-node.d.ts.map +1 -1
- package/types/state/utils/urls.d.ts.map +1 -1
- package/types/utils/json-pointer.d.ts +22 -0
- package/types/utils/json-pointer.d.ts.map +1 -0
- package/types/webmcp/index.d.ts +23 -15
- package/types/webmcp/index.d.ts.map +1 -1
- package/types/webmcp/project.d.ts +178 -57
- package/types/webmcp/project.d.ts.map +1 -1
- package/types/webmcp/resolve.d.ts +7 -3
- package/types/webmcp/resolve.d.ts.map +1 -1
- package/types/webmcp/schema.d.ts +44 -0
- package/types/webmcp/schema.d.ts.map +1 -0
- package/types/webmcp/suggestions-store.d.ts +82 -0
- package/types/webmcp/suggestions-store.d.ts.map +1 -0
- package/types/webmcp/tools/describe-state.d.ts +5 -57
- package/types/webmcp/tools/describe-state.d.ts.map +1 -1
- package/types/webmcp/tools/edit-array.d.ts +8 -37
- package/types/webmcp/tools/edit-array.d.ts.map +1 -1
- package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
- package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
- package/types/webmcp/tools/get-data.d.ts +17 -15
- package/types/webmcp/tools/get-data.d.ts.map +1 -1
- package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
- package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
- package/types/webmcp/tools/set-data.d.ts +17 -34
- package/types/webmcp/tools/set-data.d.ts.map +1 -1
- package/types/webmcp/tools/set-field-value.d.ts +20 -57
- package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
- package/types/webmcp/variants-memo.d.ts +42 -0
- package/types/webmcp/variants-memo.d.ts.map +1 -0
package/src/webmcp/resolve.js
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
* @file Node resolution for webmcp tools
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* In "menu" and "dialog" list edit modes the activated item is duplicated at the end
|
|
7
|
+
* of the children, the first occurrence being a read-only summary. Tools should always
|
|
8
|
+
* work on the editable occurrence.
|
|
9
|
+
* @param {import('../state/types.js').StateNode[]} children
|
|
10
|
+
* @param {string | number} key
|
|
11
|
+
* @returns {import('../state/types.js').StateNode|undefined}
|
|
12
|
+
*/
|
|
13
|
+
function findChild (children, key) {
|
|
14
|
+
const matches = children.filter((c) => c.key === key)
|
|
15
|
+
if (matches.length <= 1) return matches[0]
|
|
16
|
+
return matches.find((c) => !c.options.summary) ?? matches[0]
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
/**
|
|
6
20
|
* Navigate from a root StateNode to a descendant node by path.
|
|
7
21
|
* @param {import('../state/types.js').StateNode} root
|
|
@@ -18,8 +32,30 @@ export function resolveNode (root, path) {
|
|
|
18
32
|
for (const segment of segments) {
|
|
19
33
|
if (!current?.children) return undefined
|
|
20
34
|
const key = /^\d+$/.test(segment) ? parseInt(segment, 10) : segment
|
|
21
|
-
current = current.children
|
|
35
|
+
current = findChild(current.children, key)
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
return current
|
|
25
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Children of a node as they should be presented to an agent: hidden nodes are removed
|
|
43
|
+
* and the duplicated activated list item is deduplicated.
|
|
44
|
+
* @param {import('../state/types.js').StateNode} node
|
|
45
|
+
* @returns {import('../state/types.js').StateNode[]}
|
|
46
|
+
*/
|
|
47
|
+
export function visibleChildren (node) {
|
|
48
|
+
if (!node.children) return []
|
|
49
|
+
/** @type {import('../state/types.js').StateNode[]} */
|
|
50
|
+
const children = []
|
|
51
|
+
for (const child of node.children) {
|
|
52
|
+
if (child.layout.comp === 'none') continue
|
|
53
|
+
const existingIndex = children.findIndex((c) => c.fullKey === child.fullKey)
|
|
54
|
+
if (existingIndex === -1) {
|
|
55
|
+
children.push(child)
|
|
56
|
+
} else if (children[existingIndex].options.summary && !child.options.summary) {
|
|
57
|
+
children[existingIndex] = child
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return children
|
|
61
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Sub-schema resolution and skeleton projection for webmcp tools
|
|
3
|
+
* @description Large schemas cannot be serialized as a whole for a LLM agent,
|
|
4
|
+
* these utilities extract the fragment that governs a single node of the form.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolvePointerFragment } from '../utils/json-pointer.js'
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('../state/types.js').StateNode} StateNode */
|
|
10
|
+
/** @typedef {import('../state/index.js').StatefulLayout} StatefulLayout */
|
|
11
|
+
|
|
12
|
+
// keys injected in the schema by the compilation step, they are noise for a LLM agent.
|
|
13
|
+
// errorMessage is always written by skeleton-node.js, at least as an empty object; a user
|
|
14
|
+
// defined one survives in the pristine schema, which is preferred and never cleaned.
|
|
15
|
+
const internalSchemaKeys = ['__pointer', 'errorMessage']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{ key: string | number, path: string, type?: string, title?: string, required?: boolean, enum?: unknown[], declared: true }} DeclaredField
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// keywords whose value maps a name chosen by the user to a sub-schema: their keys are not
|
|
22
|
+
// schema keywords, a property may legitimately be named "errorMessage" or "__pointer"
|
|
23
|
+
const schemaMapKeys = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deep clone a schema fragment while removing the keys added by the compilation step.
|
|
27
|
+
* @param {unknown} fragment
|
|
28
|
+
* @returns {unknown}
|
|
29
|
+
*/
|
|
30
|
+
export function cleanSchemaFragment (fragment) {
|
|
31
|
+
if (Array.isArray(fragment)) return fragment.map(cleanSchemaFragment)
|
|
32
|
+
if (fragment && typeof fragment === 'object') {
|
|
33
|
+
/** @type {Record<string, unknown>} */
|
|
34
|
+
const clean = {}
|
|
35
|
+
for (const [key, value] of Object.entries(fragment)) {
|
|
36
|
+
if (internalSchemaKeys.includes(key)) continue
|
|
37
|
+
clean[key] = schemaMapKeys.includes(key) ? cleanSchemaMap(value) : cleanSchemaFragment(value)
|
|
38
|
+
}
|
|
39
|
+
return clean
|
|
40
|
+
}
|
|
41
|
+
return fragment
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Clean the sub-schemas of a map without filtering the names used as its keys.
|
|
46
|
+
* @param {unknown} map
|
|
47
|
+
* @returns {unknown}
|
|
48
|
+
*/
|
|
49
|
+
function cleanSchemaMap (map) {
|
|
50
|
+
if (!map || typeof map !== 'object' || Array.isArray(map)) return cleanSchemaFragment(map)
|
|
51
|
+
/** @type {Record<string, unknown>} */
|
|
52
|
+
const clean = {}
|
|
53
|
+
for (const [name, subSchema] of Object.entries(map)) clean[name] = cleanSchemaFragment(subSchema)
|
|
54
|
+
return clean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a schema fragment from a skeleton pointer (ex: "_jl#/properties/filters/items").
|
|
59
|
+
* @param {object} schema - a JSON schema, its $id should match the pointer prefix
|
|
60
|
+
* @param {string} pointer
|
|
61
|
+
* @returns {object|undefined}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSchemaPointer (schema, pointer) {
|
|
64
|
+
const hashIndex = pointer.indexOf('#')
|
|
65
|
+
if (hashIndex === -1) return undefined
|
|
66
|
+
const schemaId = pointer.slice(0, hashIndex)
|
|
67
|
+
const fragment = pointer.slice(hashIndex + 1)
|
|
68
|
+
const schemaObject = /** @type {Record<string, unknown>} */(schema)
|
|
69
|
+
// '_jl' is the default id given by the compilation step to an anonymous schema. A pointer into
|
|
70
|
+
// another schema is refused even when this one has no $id at all: resolving it here would
|
|
71
|
+
// silently return a same-shaped fragment of the wrong document instead of failing over.
|
|
72
|
+
if (schemaId && schemaId !== '_jl' && schemaObject.$id !== schemaId) return undefined
|
|
73
|
+
if (!fragment) return schema
|
|
74
|
+
const resolved = resolvePointerFragment(schema, fragment)
|
|
75
|
+
if (!resolved.found) return undefined
|
|
76
|
+
return resolved.value && typeof resolved.value === 'object' ? /** @type {object} */(resolved.value) : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the sub-schema that governs a node of the form.
|
|
81
|
+
* The schema given to the WebMCP instance is preferred (it is pristine),
|
|
82
|
+
* the compiled one is used as a fallback (it is cleaned up before being returned).
|
|
83
|
+
* @param {StateNode} node
|
|
84
|
+
* @param {StatefulLayout} statefulLayout
|
|
85
|
+
* @param {object|null} [originalSchema]
|
|
86
|
+
* @returns {object|undefined}
|
|
87
|
+
*/
|
|
88
|
+
export function resolveNodeSchema (node, statefulLayout, originalSchema) {
|
|
89
|
+
const pointers = [node.skeleton.refPointer, node.skeleton.pointer]
|
|
90
|
+
if (originalSchema) {
|
|
91
|
+
for (const pointer of pointers) {
|
|
92
|
+
const fragment = pointer && resolveSchemaPointer(originalSchema, pointer)
|
|
93
|
+
if (fragment) return fragment
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const compiledSchema = statefulLayout.compiledLayout.schema
|
|
97
|
+
if (compiledSchema) {
|
|
98
|
+
for (const pointer of pointers) {
|
|
99
|
+
const fragment = pointer && resolveSchemaPointer(compiledSchema, pointer)
|
|
100
|
+
if (fragment) return /** @type {object} */(cleanSchemaFragment(fragment))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Component of a node known only from its skeleton, used as a display hint.
|
|
108
|
+
* This is an approximation of the resolution done in state-node.js: a switch is resolved
|
|
109
|
+
* there by evaluating its expressions against the data, which is not available for a node
|
|
110
|
+
* the state tree did not hydrate, so the first case is taken as a representative one.
|
|
111
|
+
* @param {StatefulLayout} statefulLayout
|
|
112
|
+
* @param {string} pointer
|
|
113
|
+
* @returns {string|undefined}
|
|
114
|
+
*/
|
|
115
|
+
function getSkeletonComp (statefulLayout, pointer) {
|
|
116
|
+
const normalizedLayout = /** @type {any} */(statefulLayout.compiledLayout.normalizedLayouts[pointer])
|
|
117
|
+
if (!normalizedLayout) return undefined
|
|
118
|
+
if (typeof normalizedLayout.comp === 'string') return normalizedLayout.comp
|
|
119
|
+
if (Array.isArray(normalizedLayout.switch) && typeof normalizedLayout.switch[0]?.comp === 'string') {
|
|
120
|
+
return normalizedLayout.switch[0].comp
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List the fields declared by the skeleton of a node, even when the state tree
|
|
127
|
+
* did not hydrate them yet (a collapsed list item for example).
|
|
128
|
+
* @param {StateNode} node
|
|
129
|
+
* @param {StatefulLayout} statefulLayout
|
|
130
|
+
* @param {object|null} [originalSchema]
|
|
131
|
+
* @returns {DeclaredField[]}
|
|
132
|
+
*/
|
|
133
|
+
export function projectDeclaredFields (node, statefulLayout, originalSchema) {
|
|
134
|
+
/** @type {DeclaredField[]} */
|
|
135
|
+
const fields = []
|
|
136
|
+
let childrenKeys = node.skeleton.children ?? []
|
|
137
|
+
let pathPrefix = node.fullKey
|
|
138
|
+
if (childrenKeys.length === 0 && node.skeleton.childrenTrees?.length &&
|
|
139
|
+
(Array.isArray(node.data) || node.layout.comp === 'list')) {
|
|
140
|
+
// an array keeps the skeleton of its items in a separate tree, so the fields it declares
|
|
141
|
+
// are those of an item, addressed through an index
|
|
142
|
+
const itemSkeleton = statefulLayout.compiledLayout.skeletonNodes[node.skeleton.childrenTrees[0]]
|
|
143
|
+
childrenKeys = itemSkeleton?.children ?? []
|
|
144
|
+
pathPrefix = `${node.fullKey}/0`
|
|
145
|
+
}
|
|
146
|
+
for (const childKey of childrenKeys) {
|
|
147
|
+
const childSkeleton = statefulLayout.compiledLayout.skeletonNodes[childKey]
|
|
148
|
+
if (!childSkeleton) continue
|
|
149
|
+
const comp = getSkeletonComp(statefulLayout, childSkeleton.pointer)
|
|
150
|
+
if (comp === 'none') continue
|
|
151
|
+
/** @type {DeclaredField} */
|
|
152
|
+
const field = {
|
|
153
|
+
key: childSkeleton.key,
|
|
154
|
+
path: `${pathPrefix}/${childSkeleton.key}`,
|
|
155
|
+
declared: true
|
|
156
|
+
}
|
|
157
|
+
if (childSkeleton.title) field.title = childSkeleton.title
|
|
158
|
+
if (childSkeleton.required) field.required = true
|
|
159
|
+
const childSchema = /** @type {Record<string, any>|undefined} */(
|
|
160
|
+
(originalSchema && resolveSchemaPointer(originalSchema, childSkeleton.refPointer)) ||
|
|
161
|
+
(statefulLayout.compiledLayout.schema && resolveSchemaPointer(statefulLayout.compiledLayout.schema, childSkeleton.refPointer))
|
|
162
|
+
)
|
|
163
|
+
if (typeof childSchema?.type === 'string') field.type = childSchema.type
|
|
164
|
+
else if (comp) field.type = comp
|
|
165
|
+
if (Array.isArray(childSchema?.enum)) field.enum = childSchema.enum
|
|
166
|
+
fields.push(field)
|
|
167
|
+
}
|
|
168
|
+
return fields
|
|
169
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Memory of the last suggestions returned for each node path
|
|
3
|
+
* @description Suggestion values can be large objects, they are not returned in full
|
|
4
|
+
* to the agent. They are memorized here so that setFieldValue can reuse the original
|
|
5
|
+
* value from its index.
|
|
6
|
+
*
|
|
7
|
+
* Searches on the same path accumulate rather than replace one another, and indices are
|
|
8
|
+
* absolute across them. Replacing meant a second search silently rebound every index the
|
|
9
|
+
* first had handed out, so an agent applying an index it had been given a moment earlier
|
|
10
|
+
* would set a different value with no error — the worst kind of failure this protocol can
|
|
11
|
+
* produce, since nothing in the transcript looks wrong. A write can change another
|
|
12
|
+
* field's options, so writes drop what they invalidate — see `retainFresh`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @typedef {{value: unknown, title: string, key?: string}} SuggestionItem */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Per WebMCP instance memory of the suggestions, keyed by node path.
|
|
19
|
+
*/
|
|
20
|
+
export class SuggestionsStore {
|
|
21
|
+
/**
|
|
22
|
+
* @private
|
|
23
|
+
* @type {Map<string, SuggestionItem[]>}
|
|
24
|
+
*/
|
|
25
|
+
_byPath = new Map()
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the node's items depended on when each path was memorized, so a write can tell
|
|
29
|
+
* whether it invalidated them. See `retainFresh`.
|
|
30
|
+
* @private
|
|
31
|
+
* @type {Map<string, unknown>}
|
|
32
|
+
*/
|
|
33
|
+
_cacheKeys = new Map()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Paths that HAD memorized suggestions until a write dropped them. Kept so that the
|
|
37
|
+
* failure can say which of the two things happened: an agent told to "call
|
|
38
|
+
* getFieldSuggestions first" when it did exactly that, one call ago, cannot tell that
|
|
39
|
+
* the list went stale, and the eval shows it stops trusting suggestionIndex entirely.
|
|
40
|
+
* @private
|
|
41
|
+
* @type {Set<string>}
|
|
42
|
+
*/
|
|
43
|
+
_invalidated = new Set()
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Memorize a search's items and return the index its first item was given. Indices are
|
|
47
|
+
* absolute per path, so an index handed out earlier keeps meaning what the agent saw.
|
|
48
|
+
* @param {string} path
|
|
49
|
+
* @param {SuggestionItem[]} items
|
|
50
|
+
* @param {unknown} [cacheKey] - the node's itemsCacheKey, what its options depend on
|
|
51
|
+
* @returns {number} the index of the first of these items
|
|
52
|
+
*/
|
|
53
|
+
add (path, items, cacheKey) {
|
|
54
|
+
this._invalidated.delete(path)
|
|
55
|
+
this._cacheKeys.set(path, cacheKey)
|
|
56
|
+
const known = this._byPath.get(path)
|
|
57
|
+
if (!known) {
|
|
58
|
+
this._byPath.set(path, [...items])
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
const baseIndex = known.length
|
|
62
|
+
known.push(...items)
|
|
63
|
+
return baseIndex
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Drop only the paths a write actually invalidated.
|
|
68
|
+
*
|
|
69
|
+
* Clearing everything on every write was correct but far broader than the hazard it
|
|
70
|
+
* guarded: writing one field cannot change the options of a field whose list does not
|
|
71
|
+
* depend on it, and the eval caught the cost twice on the same case — once recovered in
|
|
72
|
+
* one call, once in three. `isFresh` is given the key recorded at `add` time so the
|
|
73
|
+
* caller can compare it with the node's current `itemsCacheKey`, which is what the state
|
|
74
|
+
* layer itself uses to decide whether to re-fetch: a resolved URL for a remote picker, so
|
|
75
|
+
* the comparison is exact where it matters, and a value that simply differs for an
|
|
76
|
+
* expression-based list, which over-invalidates in the safe direction.
|
|
77
|
+
* @param {(path: string, cacheKey: unknown) => boolean} isFresh
|
|
78
|
+
*/
|
|
79
|
+
retainFresh (isFresh) {
|
|
80
|
+
for (const path of [...this._byPath.keys()]) {
|
|
81
|
+
if (isFresh(path, this._cacheKeys.get(path))) continue
|
|
82
|
+
this._byPath.delete(path)
|
|
83
|
+
this._cacheKeys.delete(path)
|
|
84
|
+
this._invalidated.add(path)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} path
|
|
90
|
+
* @returns {SuggestionItem[]|undefined}
|
|
91
|
+
*/
|
|
92
|
+
get (path) {
|
|
93
|
+
return this._byPath.get(path)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get the full original value memorized for a path at a given index.
|
|
98
|
+
* @param {string} path
|
|
99
|
+
* @param {number} index
|
|
100
|
+
* @returns {unknown}
|
|
101
|
+
*/
|
|
102
|
+
getValue (path, index) {
|
|
103
|
+
const items = this._byPath.get(path)
|
|
104
|
+
if (!items) {
|
|
105
|
+
if (this._invalidated.has(path)) {
|
|
106
|
+
throw new Error(`the suggestions memorized for path "${path}" were dropped by a write that may have changed this field's options, call getFieldSuggestions on this path again`)
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`no suggestion memorized for path "${path}", call getFieldSuggestions on this path first`)
|
|
109
|
+
}
|
|
110
|
+
if (!Number.isInteger(index) || index < 0 || index >= items.length) {
|
|
111
|
+
throw new Error(`suggestionIndex ${index} out of bounds for path "${path}" (${items.length} suggestion(s) memorized)`)
|
|
112
|
+
}
|
|
113
|
+
return items[index].value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clear () {
|
|
117
|
+
for (const path of this._byPath.keys()) this._invalidated.add(path)
|
|
118
|
+
this._byPath.clear()
|
|
119
|
+
this._cacheKeys.clear()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -2,98 +2,54 @@
|
|
|
2
2
|
* @file describeState tool
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { collectScopedErrors, projectNodeToMarkdown, projectStateTreeToMarkdown, formatMutationResult } from '../project.js'
|
|
6
6
|
import { resolveNode } from '../resolve.js'
|
|
7
|
+
import { VariantsMemo } from '../variants-memo.js'
|
|
7
8
|
|
|
8
9
|
export const inputSchema = {
|
|
9
10
|
type: 'object',
|
|
10
11
|
properties: {
|
|
11
12
|
path: {
|
|
12
13
|
type: 'string',
|
|
13
|
-
description: '
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const outputSchema = {
|
|
19
|
-
type: 'object',
|
|
20
|
-
properties: {
|
|
21
|
-
state: {
|
|
22
|
-
type: 'object',
|
|
23
|
-
description: 'Projected state tree or single node'
|
|
24
|
-
},
|
|
25
|
-
valid: {
|
|
26
|
-
type: 'boolean'
|
|
27
|
-
},
|
|
28
|
-
errors: {
|
|
29
|
-
type: 'array',
|
|
30
|
-
items: {
|
|
31
|
-
type: 'object',
|
|
32
|
-
properties: {
|
|
33
|
-
path: { type: 'string' },
|
|
34
|
-
message: { type: 'string' }
|
|
35
|
-
}
|
|
36
|
-
}
|
|
14
|
+
description: 'Node path as returned by describeState (e.g. "/address/city"). Omit for the whole tree.'
|
|
37
15
|
}
|
|
38
16
|
}
|
|
39
17
|
}
|
|
40
18
|
|
|
41
19
|
/**
|
|
42
20
|
* @param {string} dataTitle
|
|
43
|
-
* @param {"small"|"medium"|"large"} [complexity]
|
|
44
21
|
* @returns {string}
|
|
45
22
|
*/
|
|
46
|
-
export function getDescription (dataTitle
|
|
47
|
-
|
|
48
|
-
if (complexity === 'large') {
|
|
49
|
-
desc += ' Use the "path" parameter to focus on a subtree — avoid calling without a path on large forms.'
|
|
50
|
-
}
|
|
51
|
-
return desc
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* @param {import('../../state/index.js').StatefulLayout} statefulLayout
|
|
56
|
-
* @param {{ path?: string }} args
|
|
57
|
-
* @returns {{state: ReturnType<typeof projectStateTree>|ReturnType<typeof projectNode>, valid: boolean, errors: Array<{path: string, message: string}>}}
|
|
58
|
-
*/
|
|
59
|
-
export function execute (statefulLayout, args) {
|
|
60
|
-
const errors = collectErrors(statefulLayout.stateTree.root)
|
|
61
|
-
|
|
62
|
-
if (args.path) {
|
|
63
|
-
const node = resolveNode(statefulLayout.stateTree.root, args.path)
|
|
64
|
-
if (!node) {
|
|
65
|
-
throw new Error(`node not found at path: ${args.path}`)
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
state: projectNode(node, statefulLayout),
|
|
69
|
-
valid: statefulLayout.valid,
|
|
70
|
-
errors
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
state: projectStateTree(statefulLayout.stateTree, statefulLayout),
|
|
76
|
-
valid: statefulLayout.valid,
|
|
77
|
-
errors
|
|
78
|
-
}
|
|
23
|
+
export function getDescription (dataTitle) {
|
|
24
|
+
return `Describe the "${dataTitle}" form: every field with its path, type, constraints, current value and errors. Pass "path" to describe one subtree instead of the whole form.`
|
|
79
25
|
}
|
|
80
26
|
|
|
81
27
|
/**
|
|
82
28
|
* @param {import('../../state/index.js').StatefulLayout} statefulLayout
|
|
83
29
|
* @param {{ path?: string }} args
|
|
30
|
+
* @param {import('../variants-memo.js').VariantsMemo} [variantsMemo] - updated, not consulted:
|
|
31
|
+
* a read is what the agent asked to see, so every union under it is listed in full, and the
|
|
32
|
+
* memo is what later writes use to avoid repeating those lists
|
|
84
33
|
* @returns {string}
|
|
85
34
|
*/
|
|
86
|
-
export function toMarkdown (statefulLayout, args) {
|
|
35
|
+
export function toMarkdown (statefulLayout, args, variantsMemo) {
|
|
36
|
+
const listed = new VariantsMemo()
|
|
37
|
+
|
|
87
38
|
if (args.path) {
|
|
88
39
|
const node = resolveNode(statefulLayout.stateTree.root, args.path)
|
|
89
40
|
if (!node) {
|
|
90
41
|
throw new Error(`node not found at path: ${args.path}`)
|
|
91
42
|
}
|
|
92
|
-
const errors =
|
|
93
|
-
|
|
94
|
-
projectNodeToMarkdown(node, statefulLayout)
|
|
43
|
+
const { errors, otherErrors } = collectScopedErrors(statefulLayout, node)
|
|
44
|
+
const markdown = formatMutationResult(statefulLayout.valid, errors,
|
|
45
|
+
projectNodeToMarkdown(node, statefulLayout, 0, undefined, listed),
|
|
46
|
+
otherErrors
|
|
95
47
|
)
|
|
48
|
+
variantsMemo?.merge(listed)
|
|
49
|
+
return markdown
|
|
96
50
|
}
|
|
97
51
|
|
|
98
|
-
|
|
52
|
+
const markdown = projectStateTreeToMarkdown(statefulLayout.stateTree, statefulLayout, listed)
|
|
53
|
+
variantsMemo?.merge(listed)
|
|
54
|
+
return markdown
|
|
99
55
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @file editArray tool
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { collectScopedErrors, projectNodeToMarkdown } from '../project.js'
|
|
6
6
|
import { resolveNode } from '../resolve.js'
|
|
7
7
|
|
|
8
8
|
export const inputSchema = {
|
|
@@ -10,7 +10,7 @@ export const inputSchema = {
|
|
|
10
10
|
properties: {
|
|
11
11
|
path: {
|
|
12
12
|
type: 'string',
|
|
13
|
-
description: '
|
|
13
|
+
description: 'Node path as returned by describeState (e.g. "/address/city"). Must be an array field.'
|
|
14
14
|
},
|
|
15
15
|
action: {
|
|
16
16
|
type: 'string',
|
|
@@ -28,38 +28,23 @@ export const inputSchema = {
|
|
|
28
28
|
required: ['path', 'action']
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export const outputSchema = {
|
|
32
|
-
type: 'object',
|
|
33
|
-
properties: {
|
|
34
|
-
valid: { type: 'boolean' },
|
|
35
|
-
itemCount: { type: 'number' },
|
|
36
|
-
errors: {
|
|
37
|
-
type: 'array',
|
|
38
|
-
items: {
|
|
39
|
-
type: 'object',
|
|
40
|
-
properties: {
|
|
41
|
-
path: { type: 'string' },
|
|
42
|
-
message: { type: 'string' }
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
31
|
/**
|
|
50
32
|
* @param {string} dataTitle
|
|
51
33
|
* @returns {string}
|
|
52
34
|
*/
|
|
53
35
|
export function getDescription (dataTitle) {
|
|
54
|
-
return `Add or remove items in an array field of "${dataTitle}". Use describeState to see current array contents.`
|
|
36
|
+
return `Add or remove items in an array field of "${dataTitle}". Use describeState to see current array contents. When adding an item it is activated for edition and its children fields are returned, they can then be filled with setFieldValue. The returned errors are scoped to this array.`
|
|
55
37
|
}
|
|
56
38
|
|
|
57
39
|
/**
|
|
58
40
|
* @param {import('../../state/index.js').StatefulLayout} statefulLayout
|
|
59
41
|
* @param {{ path: string, action: 'add'|'remove', index?: number, value?: unknown }} args
|
|
60
|
-
* @
|
|
42
|
+
* @param {import('../variants-memo.js').VariantsMemo} [variantsMemo] - a new item of a
|
|
43
|
+
* recursive schema carries the same union as the item that contains it; the memo keeps the
|
|
44
|
+
* response from printing it again
|
|
45
|
+
* @returns {{ valid: boolean, itemCount: number, index: number, itemMarkdown?: string, errors: Array<{path: string, message: string}>, otherErrors: number }}
|
|
61
46
|
*/
|
|
62
|
-
export function execute (statefulLayout, args) {
|
|
47
|
+
export function execute (statefulLayout, args, variantsMemo) {
|
|
63
48
|
const node = resolveNode(statefulLayout.stateTree.root, args.path)
|
|
64
49
|
if (!node) {
|
|
65
50
|
throw new Error(`node not found at path: ${args.path}`)
|
|
@@ -70,16 +55,24 @@ export function execute (statefulLayout, args) {
|
|
|
70
55
|
}
|
|
71
56
|
|
|
72
57
|
const currentData = Array.isArray(node.data) ? [...node.data] : []
|
|
58
|
+
// input() drops the activation when the array shrinks, so it has to be read before
|
|
59
|
+
const activatedBefore = statefulLayout.activatedItems[node.fullKey]
|
|
60
|
+
let index
|
|
73
61
|
|
|
74
62
|
if (args.action === 'add') {
|
|
75
|
-
|
|
63
|
+
index = args.index !== undefined ? args.index : currentData.length
|
|
64
|
+
// splice() would silently clamp an out of bounds index, but the reported index and the
|
|
65
|
+
// item activated below would then designate an item that does not exist
|
|
66
|
+
if (!Number.isInteger(index) || index < 0 || index > currentData.length) {
|
|
67
|
+
throw new Error(`index ${index} out of bounds (array length: ${currentData.length}, an item can be added at 0 to ${currentData.length})`)
|
|
68
|
+
}
|
|
76
69
|
currentData.splice(index, 0, args.value !== undefined ? args.value : undefined)
|
|
77
70
|
} else if (args.action === 'remove') {
|
|
78
71
|
if (currentData.length === 0) {
|
|
79
72
|
throw new Error('cannot remove from an empty array')
|
|
80
73
|
}
|
|
81
|
-
|
|
82
|
-
if (index < 0 || index >= currentData.length) {
|
|
74
|
+
index = args.index !== undefined ? args.index : currentData.length - 1
|
|
75
|
+
if (!Number.isInteger(index) || index < 0 || index >= currentData.length) {
|
|
83
76
|
throw new Error(`index ${index} out of bounds (array length: ${currentData.length})`)
|
|
84
77
|
}
|
|
85
78
|
currentData.splice(index, 1)
|
|
@@ -89,9 +82,39 @@ export function execute (statefulLayout, args) {
|
|
|
89
82
|
|
|
90
83
|
statefulLayout.input(node, currentData)
|
|
91
84
|
|
|
92
|
-
|
|
85
|
+
// the list components only hydrate the editable children of an item when it is activated,
|
|
86
|
+
// the same activation is applied here so that the agent can fill the new item
|
|
87
|
+
const listEditMode = /** @type {Record<string, unknown>} */(node.layout).listEditMode
|
|
88
|
+
const listNodeAfterInput = resolveNode(statefulLayout.stateTree.root, args.path)
|
|
89
|
+
if (listNodeAfterInput) {
|
|
90
|
+
if (args.action === 'add' && listEditMode !== 'inline') {
|
|
91
|
+
statefulLayout.activateItem(listNodeAfterInput, index)
|
|
92
|
+
} else if (args.action === 'remove' && typeof activatedBefore === 'number' && activatedBefore !== index) {
|
|
93
|
+
// input() dropped the activation, but the item being edited is not the one that was
|
|
94
|
+
// removed: it is restored, shifted down by one when it was after the removed item, so
|
|
95
|
+
// that the agent does not silently lose the item it was filling
|
|
96
|
+
statefulLayout.activateItem(listNodeAfterInput, activatedBefore > index ? activatedBefore - 1 : activatedBefore)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const listNode = resolveNode(statefulLayout.stateTree.root, args.path) ?? node
|
|
101
|
+
const { errors, otherErrors } = collectScopedErrors(statefulLayout, listNode)
|
|
102
|
+
|
|
103
|
+
/** @type {{ valid: boolean, itemCount: number, index: number, itemMarkdown?: string, errors: Array<{path: string, message: string}>, otherErrors: number }} */
|
|
104
|
+
const result = {
|
|
93
105
|
valid: statefulLayout.valid,
|
|
94
106
|
itemCount: currentData.length,
|
|
95
|
-
|
|
107
|
+
index,
|
|
108
|
+
errors,
|
|
109
|
+
otherErrors
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (args.action === 'add') {
|
|
113
|
+
const itemNode = resolveNode(statefulLayout.stateTree.root, `${args.path}/${index}`)
|
|
114
|
+
if (itemNode) {
|
|
115
|
+
result.itemMarkdown = projectNodeToMarkdown(itemNode, statefulLayout, 0, undefined, variantsMemo)
|
|
116
|
+
}
|
|
96
117
|
}
|
|
118
|
+
|
|
119
|
+
return result
|
|
97
120
|
}
|
|
@@ -2,13 +2,6 @@
|
|
|
2
2
|
* @file fillFormSkill tool
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export const outputSchema = {
|
|
6
|
-
type: 'object',
|
|
7
|
-
properties: {
|
|
8
|
-
content: { type: 'string' }
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
5
|
/**
|
|
13
6
|
* @param {string} dataTitle
|
|
14
7
|
* @returns {string}
|
|
@@ -18,50 +11,33 @@ export function getDescription (dataTitle) {
|
|
|
18
11
|
}
|
|
19
12
|
|
|
20
13
|
/**
|
|
21
|
-
*
|
|
14
|
+
* The one path. There used to be three, chosen by counting normalized layouts and calling
|
|
15
|
+
* the result small, medium or large — a threshold nobody could justify, measuring the
|
|
16
|
+
* schema when the question was about data, and duplicated between here and index.js. Four
|
|
17
|
+
* commits in a row went to fixing contradictions between those branches, and across four
|
|
18
|
+
* recorded baselines the small and medium advice was never taken on a real form: setData
|
|
19
|
+
* was used zero times and getSchema once.
|
|
22
20
|
* @param {string} dataTitle
|
|
23
21
|
* @param {string} prefixName
|
|
24
|
-
* @param {boolean} hasSchema
|
|
25
|
-
* @param {import('../../state/index.js').StatefulLayout} statefulLayout
|
|
26
22
|
* @returns {string}
|
|
27
23
|
*/
|
|
28
|
-
export function generateSkill (dataTitle, prefixName
|
|
29
|
-
|
|
30
|
-
let complexity = 'small'
|
|
31
|
-
const nbNormalizedLayouts = Object.keys(statefulLayout.compiledLayout.normalizedLayouts).length
|
|
32
|
-
if (nbNormalizedLayouts > 15) complexity = 'medium'
|
|
33
|
-
if (nbNormalizedLayouts > 50) complexity = 'large'
|
|
34
|
-
let skill = `# JSON ${dataTitle.charAt(0).toUpperCase() + dataTitle.slice(1)} Form-Filling Guide
|
|
24
|
+
export function generateSkill (dataTitle, prefixName) {
|
|
25
|
+
return `# JSON ${dataTitle.charAt(0).toUpperCase() + dataTitle.slice(1)} Form-Filling Guide
|
|
35
26
|
|
|
36
27
|
This guide teaches you how to use tools to fill the data of a form in the user's page.
|
|
37
28
|
|
|
38
|
-
|
|
39
|
-
`
|
|
29
|
+
Start with ${prefixName}describeState. It lists every field with its path, its current value and anything invalid; a value too large to inline is shown as its type and size, with the path to read it. Call it again on a path whenever you need to look at one part of the form.
|
|
40
30
|
|
|
41
|
-
|
|
42
|
-
skill += `
|
|
43
|
-
Given the small complexity of this form you should start by reading the full schema definition using ${prefixName}${hasSchema ? 'getSchema' : 'describeState'} and attempt updating the whole data using ${prefixName}setData.
|
|
44
|
-
Only use ${prefixName}describeState and iterate with ${prefixName}setFieldValue if you encounter some difficulties with ${prefixName}setData.
|
|
45
|
-
`
|
|
46
|
-
}
|
|
31
|
+
Then write. If the goal already tells you every value, set them in one call with ${prefixName}setData. Otherwise change one field at a time with ${prefixName}setFieldValue, which is also what you need when a value has to be looked up first or when a field only exists once another has been set. Every write reports whether the form is valid and lists what is wrong, so you rarely need to read anything back.
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
skill += `
|
|
50
|
-
Given the medium complexity of this form you should start by reading the full schema definition using ${prefixName}${hasSchema ? 'getSchema' : 'describeState'}, if you have a satisfying understanding of the schema you can attempt updating the whole data using ${prefixName}setData at least once.
|
|
51
|
-
Then use ${prefixName}describeState and iterate with ${prefixName}setFieldValue.
|
|
52
|
-
`
|
|
53
|
-
}
|
|
33
|
+
Never invent a value for a field that has a fixed set of accepted ones. ${prefixName}describeState tells you which case you are in: it either states them on the field's line as values=[...], and you write one of those directly, or it marks the field "suggestions", and the list exists only behind a request — then call ${prefixName}getFieldSuggestions, whose description says how to apply what it returns.
|
|
54
34
|
|
|
55
|
-
|
|
56
|
-
skill += `
|
|
57
|
-
Given the large complexity of this form you should avoid reading the full schema definition using ${prefixName}${hasSchema ? 'getSchema' : 'describeState'}.
|
|
58
|
-
Prefer using ${prefixName}describeState and iterating with ${prefixName}setFieldValue.
|
|
59
|
-
`
|
|
60
|
-
}
|
|
35
|
+
A field shown as (variant-selector) chooses between shapes rather than between values: ${prefixName}describeState lists its branches under it as "variant N: label", and you switch to one by setting the field to that number with ${prefixName}setFieldValue, which then lists the fields the branch contains.
|
|
61
36
|
|
|
62
|
-
|
|
63
|
-
If you encounter getItems definitions in the schema or "suggestions" flags in the state, you must use ${prefixName}getFieldSuggestions to fetch the accepted values, then pass the chosen value directly to ${prefixName}setFieldValue or include it in ${prefixName}setData.
|
|
64
|
-
`
|
|
37
|
+
To fill an array, call ${prefixName}editArray with action "add": the new item is activated for edition and the tool returns the fields it contains, then fill them one by one with ${prefixName}setFieldValue.
|
|
65
38
|
|
|
66
|
-
|
|
39
|
+
${prefixName}getData returns the data document itself, whole or one part of it by path, for when you need the values rather than a description of them.
|
|
40
|
+
|
|
41
|
+
The errors returned by ${prefixName}setFieldValue and ${prefixName}editArray are scoped to the node you just modified, other errors of the form are only counted.
|
|
42
|
+
`
|
|
67
43
|
}
|