@nxisai/agent-harness 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +31 -9
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +7 -5
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +33 -12
- package/dist/harness/tools/bridge.js.map +1 -1
- package/dist/harness/tools/local-context.d.ts +49 -9
- package/dist/harness/tools/local-context.d.ts.map +1 -1
- package/dist/harness/tools/local-context.js +377 -22
- package/dist/harness/tools/local-context.js.map +1 -1
- package/dist/harness/tools/open-page.d.ts +16 -0
- package/dist/harness/tools/open-page.d.ts.map +1 -1
- package/dist/harness/tools/open-page.js +45 -9
- package/dist/harness/tools/open-page.js.map +1 -1
- package/dist/harness/tools/retry.d.ts +53 -0
- package/dist/harness/tools/retry.d.ts.map +1 -0
- package/dist/harness/tools/retry.js +96 -0
- package/dist/harness/tools/retry.js.map +1 -0
- package/dist/harness/tools/schema.d.ts +18 -0
- package/dist/harness/tools/schema.d.ts.map +1 -1
- package/dist/harness/tools/schema.js +38 -0
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +3 -2
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/site-nav.d.ts +168 -0
- package/dist/harness/tools/site-nav.d.ts.map +1 -0
- package/dist/harness/tools/site-nav.js +316 -0
- package/dist/harness/tools/site-nav.js.map +1 -0
- package/dist/harness/tools/site-pages.d.ts +4 -0
- package/dist/harness/tools/site-pages.d.ts.map +1 -1
- package/dist/harness/tools/site-pages.js +19 -3
- package/dist/harness/tools/site-pages.js.map +1 -1
- package/dist/harness/tools/source.d.ts +9 -0
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +54 -14
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/harness/tools/webmcp.d.ts +39 -3
- package/dist/harness/tools/webmcp.d.ts.map +1 -1
- package/dist/harness/tools/webmcp.js +93 -14
- package/dist/harness/tools/webmcp.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +12 -1
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +2 -1
- package/src/a2ui/cards.ts +177 -0
- package/src/a2ui/emit.ts +147 -0
- package/src/a2ui/form.ts +117 -0
- package/src/a2ui/host.ts +73 -0
- package/src/a2ui/index.ts +22 -0
- package/src/a2ui/stepper.ts +211 -0
- package/src/a2ui/theme.ts +134 -0
- package/src/a2ui/to-v09.ts +118 -0
- package/src/engine.ts +701 -0
- package/src/harness/harness.ts +956 -0
- package/src/harness/plan.ts +91 -0
- package/src/harness/policy.ts +59 -0
- package/src/harness/renderer.ts +208 -0
- package/src/harness/router/coordinator.ts +220 -0
- package/src/harness/router/rules.ts +65 -0
- package/src/harness/slots.ts +117 -0
- package/src/harness/steps.ts +156 -0
- package/src/harness/tools/action-tools.ts +870 -0
- package/src/harness/tools/bridge.ts +393 -0
- package/src/harness/tools/client.ts +26 -0
- package/src/harness/tools/discovery.ts +55 -0
- package/src/harness/tools/local-context.ts +480 -0
- package/src/harness/tools/open-page.ts +273 -0
- package/src/harness/tools/pending-action.ts +232 -0
- package/src/harness/tools/resume.ts +134 -0
- package/src/harness/tools/retry.ts +117 -0
- package/src/harness/tools/schema.ts +245 -0
- package/src/harness/tools/site-actions.ts +356 -0
- package/src/harness/tools/site-nav.ts +432 -0
- package/src/harness/tools/site-pages.ts +288 -0
- package/src/harness/tools/source.ts +353 -0
- package/src/harness/tools/webmcp.ts +200 -0
- package/src/index.ts +193 -0
- package/src/providers/capability.ts +167 -0
- package/src/providers/proxy.ts +191 -0
- package/src/providers/types.ts +150 -0
- package/src/types.ts +258 -0
- package/src/ui/agent-fullscreen.ts +3984 -0
- package/src/ui/agent.tw.css +49 -0
- package/src/ui/config.ts +138 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/journey.ts +57 -0
- package/src/ui/tailwind.generated.ts +2 -0
- package/src/webmcp-contract.ts +65 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting out a server that is rate limiting us.
|
|
3
|
+
*
|
|
4
|
+
* The site's MCP server answers a read over its limit with the limit, and how
|
|
5
|
+
* long to wait: "You have used all 3 read requests allowed per 60s on the public
|
|
6
|
+
* plan. Retry after 52s." That is an instruction, not a failure — a turn that
|
|
7
|
+
* gives up on it tells the visitor the site has no data, which is false, and a
|
|
8
|
+
* turn that retries immediately makes it worse.
|
|
9
|
+
*
|
|
10
|
+
* The wait is the server's number, never a guess of ours, and it is bounded: a
|
|
11
|
+
* visitor is owed an answer sooner than a minute, so a long wait is refused and
|
|
12
|
+
* reported rather than sat through.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How many times a rate-limited call is retried, by default. */
|
|
18
|
+
const ATTEMPTS = 2;
|
|
19
|
+
/** The longest we will wait on one attempt. Past this, the visitor is told. */
|
|
20
|
+
const MAX_WAIT_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
/** "Retry after 52s", "retry-after: 52", "try again in 52 seconds". */
|
|
23
|
+
const AFTER = /retry[-\s]?after:?\s*(\d+)\s*(s|sec|secs|seconds)?\b|try again in\s*(\d+)\s*(s|sec|secs|seconds)\b/i;
|
|
24
|
+
/** What a server says when it is us, not the request, that it is refusing. */
|
|
25
|
+
const LIMITED = /rate[-\s]?limit|resource[-_\s]?exhausted|too many requests|requests allowed per|429/i;
|
|
26
|
+
|
|
27
|
+
const textOf = (err: unknown): string => {
|
|
28
|
+
if (typeof err === 'string') return err;
|
|
29
|
+
const e = err as { message?: string; content?: { text?: string }[] };
|
|
30
|
+
const message = typeof e?.message === 'string' ? e.message : '';
|
|
31
|
+
const content = Array.isArray(e?.content) ? e.content.map((c) => c?.text ?? '').join(' ') : '';
|
|
32
|
+
return `${message} ${content}`.trim();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a failure is the server asking us to wait.
|
|
37
|
+
*
|
|
38
|
+
* Read from what it said. The transport carries a numeric code too, but each
|
|
39
|
+
* hop maps it differently, and the sentence survives every hop.
|
|
40
|
+
*/
|
|
41
|
+
export function isRateLimited(err: unknown): boolean {
|
|
42
|
+
const text = textOf(err);
|
|
43
|
+
return LIMITED.test(text) || AFTER.test(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How long the server asked us to wait, in milliseconds.
|
|
48
|
+
*
|
|
49
|
+
* @returns The advised wait, or null when it named none.
|
|
50
|
+
*/
|
|
51
|
+
export function retryAfterMs(err: unknown): number | null {
|
|
52
|
+
const match = AFTER.exec(textOf(err));
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
const seconds = Number(match[1] ?? match[3]);
|
|
55
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Overrides, so the waiting can be exercised without spending it. */
|
|
59
|
+
export interface RetryOptions {
|
|
60
|
+
/** Retries after the first try. Defaults to two. */
|
|
61
|
+
attempts?: number;
|
|
62
|
+
/** The longest single wait to accept. Defaults to a minute. */
|
|
63
|
+
maxWaitMs?: number;
|
|
64
|
+
/** Abort while waiting. */
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
/** How to wait. Replaced in tests. */
|
|
67
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
68
|
+
/** Told before each wait, for a surface that wants to say "one moment". */
|
|
69
|
+
onWait?: (ms: number, err: unknown) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const defaultSleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
73
|
+
new Promise((resolve, reject) => {
|
|
74
|
+
if (signal?.aborted) return reject(signal.reason ?? new Error('aborted'));
|
|
75
|
+
const timer = setTimeout(resolve, ms);
|
|
76
|
+
signal?.addEventListener(
|
|
77
|
+
'abort',
|
|
78
|
+
() => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
81
|
+
},
|
|
82
|
+
{ once: true },
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run a call, waiting out a rate limit as the server asked.
|
|
88
|
+
*
|
|
89
|
+
* Anything else — a network failure, a bad request, a tool that refused — is
|
|
90
|
+
* raised as it happened: this waits for permission, it does not paper over
|
|
91
|
+
* errors by trying again.
|
|
92
|
+
*
|
|
93
|
+
* @param run - The call, run afresh on each attempt.
|
|
94
|
+
* @param opts - Attempts, ceiling, the clock and the abort signal.
|
|
95
|
+
*/
|
|
96
|
+
export async function withRetry<T>(run: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
|
|
97
|
+
const attempts = opts.attempts ?? ATTEMPTS;
|
|
98
|
+
const ceiling = opts.maxWaitMs ?? MAX_WAIT_MS;
|
|
99
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
100
|
+
|
|
101
|
+
for (let attempt = 0; ; attempt++) {
|
|
102
|
+
try {
|
|
103
|
+
return await run();
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const advised = retryAfterMs(err);
|
|
106
|
+
const waitable = isRateLimited(err) && attempt < attempts;
|
|
107
|
+
// A wait longer than the ceiling is the server saying "not for a while":
|
|
108
|
+
// sitting on it would hold the turn open with nothing happening.
|
|
109
|
+
if (!waitable || (advised ?? 0) > ceiling) throw err;
|
|
110
|
+
// A limit with no number attached: back off, so a second client does not
|
|
111
|
+
// retry in lockstep with this one.
|
|
112
|
+
const wait = advised ?? Math.min(ceiling, 1000 * 2 ** attempt + Math.floor(Math.random() * 400));
|
|
113
|
+
opts.onWait?.(wait, err);
|
|
114
|
+
await sleep(wait, opts.signal);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a tool's JSON Schema, and reconciling model output against it.
|
|
3
|
+
*
|
|
4
|
+
* Model-agnostic: these helpers work the same whichever provider produced the
|
|
5
|
+
* arguments, so they sit with the harness rather than behind
|
|
6
|
+
* {@link ModelProvider}.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
JsonSchema,
|
|
12
|
+
ModelContextToolInfo,
|
|
13
|
+
ModelContextToolResponse,
|
|
14
|
+
} from '../../types.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read a tool's input schema, whichever key and form it arrives in.
|
|
18
|
+
*
|
|
19
|
+
* Accepts every alias on {@link ModelContextToolInfo}, parses a schema supplied
|
|
20
|
+
* as a JSON string, merges in any required fields the page's own markup
|
|
21
|
+
* declares, and gives every node a `type` so a chat template that formats types
|
|
22
|
+
* cannot fail on an untyped one.
|
|
23
|
+
*
|
|
24
|
+
* @param tool - The tool as reported by discovery.
|
|
25
|
+
* @returns The normalised schema, or an empty object when there is none.
|
|
26
|
+
*/
|
|
27
|
+
export function toolSchema(tool: ModelContextToolInfo): JsonSchema {
|
|
28
|
+
let raw: JsonSchema | string | undefined =
|
|
29
|
+
tool && (tool.inputSchema ?? tool.input_schema ?? tool.parameters ?? tool.schema);
|
|
30
|
+
if (typeof raw === 'string') {
|
|
31
|
+
try {
|
|
32
|
+
raw = JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
raw = undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const schema: JsonSchema = raw && typeof raw === 'object' ? raw : {};
|
|
38
|
+
// The page's own markup is authoritative about what is required: a backend
|
|
39
|
+
// schema may omit fields the rendered form still demands.
|
|
40
|
+
const props = schema.properties ? Object.keys(schema.properties) : [];
|
|
41
|
+
if (props.length) {
|
|
42
|
+
const domReq = _domRequired(props);
|
|
43
|
+
if (domReq.length) schema.required = [...new Set([...(schema.required || []), ...domReq])];
|
|
44
|
+
}
|
|
45
|
+
// Some schemas declare properties with no `type`. Gemma's tool chat template
|
|
46
|
+
// applies `| upper` to each parameter type and throws on an undefined one, so
|
|
47
|
+
// every node is given a type before prompt building.
|
|
48
|
+
return _normalizeSchema(schema, true);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Which of a tool's required fields have no usable value.
|
|
53
|
+
*
|
|
54
|
+
* @param tool - The tool whose schema declares the requirements.
|
|
55
|
+
* @param args - The arguments so far.
|
|
56
|
+
* @returns Required field names that are absent, null, or blank.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* A read's answer when it is prose rather than records.
|
|
60
|
+
*
|
|
61
|
+
* Some reads answer in words — where the visitor is, that they are already on
|
|
62
|
+
* the page they asked for — and a surface that can only draw record cards shows
|
|
63
|
+
* an empty one reading "No results found." directly under the answer it was
|
|
64
|
+
* given. This is how a surface tells the two apart.
|
|
65
|
+
*
|
|
66
|
+
* Empty when the answer parses as JSON, or says nothing: that is records a card
|
|
67
|
+
* knows how to draw, or nothing worth putting in front of anyone. The envelope
|
|
68
|
+
* a registry wraps a response in is never consulted — a native
|
|
69
|
+
* `document.modelContext` attaches fields of its own to every response, and
|
|
70
|
+
* judging by those is what put "No results found." under an answer in words.
|
|
71
|
+
*
|
|
72
|
+
* @param res - The tool response, or the text it was flattened to.
|
|
73
|
+
* @returns The sentence to show, or an empty string to draw records instead.
|
|
74
|
+
*/
|
|
75
|
+
export function proseAnswer(res: ModelContextToolResponse | string | null): string {
|
|
76
|
+
// Always through extractResult, strings included. Chrome's native registry returns the whole
|
|
77
|
+
// response serialized — '{"content":[{"type":"text","text":"You are on …"}]}' — and read as-is that
|
|
78
|
+
// parses as JSON and was taken for records, so the answer only ever reached the debug trace.
|
|
79
|
+
const text = extractResult(res);
|
|
80
|
+
const trimmed = (text ?? '').trim();
|
|
81
|
+
if (!trimmed) return '';
|
|
82
|
+
// Decided from the answer itself, never from the envelope around it. A registry attaches its own
|
|
83
|
+
// fields — `structuredContent`, a wrapper, whatever the implementation adds — and judging by
|
|
84
|
+
// those is how an answer in plain words ended up drawn as an empty result card.
|
|
85
|
+
try {
|
|
86
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
87
|
+
if (parsed && typeof parsed === 'object') return '';
|
|
88
|
+
} catch {
|
|
89
|
+
// Not records. Words.
|
|
90
|
+
}
|
|
91
|
+
return trimmed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function missingRequired(tool: ModelContextToolInfo, args: Record<string, unknown> | undefined): string[] {
|
|
95
|
+
const required = toolSchema(tool).required || [];
|
|
96
|
+
return required.filter((k) => {
|
|
97
|
+
const v = args ? args[k] : undefined;
|
|
98
|
+
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Map a model's argument keys onto the schema's real property names.
|
|
104
|
+
*
|
|
105
|
+
* Models paraphrase field names. Keys are matched exactly first, then
|
|
106
|
+
* case- and punctuation-insensitively, then by prefix, then by shared word.
|
|
107
|
+
* Values matching an enum are snapped to the enum's own casing. A key with no
|
|
108
|
+
* plausible match is passed through unchanged.
|
|
109
|
+
*
|
|
110
|
+
* @param args - The arguments the model supplied.
|
|
111
|
+
* @param schema - The tool's normalised schema.
|
|
112
|
+
* @returns The arguments rekeyed to schema property names.
|
|
113
|
+
*/
|
|
114
|
+
export function reconcileArgs(
|
|
115
|
+
args: Record<string, unknown> | undefined,
|
|
116
|
+
schema: JsonSchema,
|
|
117
|
+
): Record<string, unknown> {
|
|
118
|
+
const props = schema && schema.properties ? Object.keys(schema.properties) : [];
|
|
119
|
+
if (!props.length || !args) return args || {};
|
|
120
|
+
const norm = (s: string) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
121
|
+
const toks = (s: string) =>
|
|
122
|
+
String(s)
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.split(/[^a-z0-9]+/)
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
const out: Record<string, unknown> = {};
|
|
127
|
+
for (const [key, value] of Object.entries(args)) {
|
|
128
|
+
if (props.includes(key)) {
|
|
129
|
+
out[key] = _snapEnum(value, schema.properties![key]);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const nk = norm(key);
|
|
133
|
+
let best: string | undefined = props.find((p) => norm(p) === nk);
|
|
134
|
+
if (!best) best = props.find((p) => nk.startsWith(norm(p)) || norm(p).startsWith(nk));
|
|
135
|
+
if (!best) {
|
|
136
|
+
const ktoks = new Set(toks(key));
|
|
137
|
+
let bestScore = 0;
|
|
138
|
+
for (const p of props) {
|
|
139
|
+
const score = toks(p).filter((t) => ktoks.has(t)).length;
|
|
140
|
+
if (score > bestScore) {
|
|
141
|
+
bestScore = score;
|
|
142
|
+
best = p;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (bestScore === 0) best = undefined;
|
|
146
|
+
}
|
|
147
|
+
const targetKey = best || key;
|
|
148
|
+
out[targetKey] = _snapEnum(value, schema.properties![targetKey]);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Flatten a tool response to text.
|
|
155
|
+
*
|
|
156
|
+
* @param res - The response, a plain string, or nothing.
|
|
157
|
+
* @returns The response's text blocks joined, or its JSON form when it carries
|
|
158
|
+
* no text. Empty for a null or undefined response.
|
|
159
|
+
*/
|
|
160
|
+
export function extractResult(res: ModelContextToolResponse | string | null | undefined): string {
|
|
161
|
+
if (res == null) return '';
|
|
162
|
+
if (typeof res === 'string') return unwrapResponse(res);
|
|
163
|
+
if (Array.isArray(res.content)) {
|
|
164
|
+
return res.content
|
|
165
|
+
.map((c) => unwrapResponse((c && c.text) || ''))
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.join('\n');
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return JSON.stringify(res);
|
|
171
|
+
} catch {
|
|
172
|
+
return String(res);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── internals ───────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A response the registry stringified rather than passed through.
|
|
180
|
+
*
|
|
181
|
+
* A tool may answer with a response object, and a registry is free to hand
|
|
182
|
+
* that back as JSON in a single text block — which puts `{"content":[{"type":
|
|
183
|
+
* "text"…` in front of the user where a sentence belongs. Only a shape that is
|
|
184
|
+
* plainly a tool response is unwrapped; a tool whose own result happens to be
|
|
185
|
+
* JSON keeps it.
|
|
186
|
+
*/
|
|
187
|
+
function unwrapResponse(text: string): string {
|
|
188
|
+
const trimmed = text.trim();
|
|
189
|
+
if (!trimmed.startsWith('{') || !trimmed.includes('"content"')) return text;
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(trimmed) as { content?: { text?: string }[] };
|
|
192
|
+
if (!Array.isArray(parsed.content)) return text;
|
|
193
|
+
const inner = parsed.content
|
|
194
|
+
.map((c) => (c && c.text) || '')
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.join('\n');
|
|
197
|
+
return inner || text;
|
|
198
|
+
} catch {
|
|
199
|
+
return text;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Give every schema node a `type`, which the chat template requires.
|
|
205
|
+
*
|
|
206
|
+
* Nodes with properties become `object`, nodes with items become `array`, and
|
|
207
|
+
* remaining leaves become `string`. Mutates and returns the schema.
|
|
208
|
+
*/
|
|
209
|
+
function _normalizeSchema(schema: JsonSchema, isRoot = false): JsonSchema {
|
|
210
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
211
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
212
|
+
if (!schema.type) schema.type = 'object';
|
|
213
|
+
for (const key of Object.keys(schema.properties)) _normalizeSchema(schema.properties[key]);
|
|
214
|
+
}
|
|
215
|
+
if (schema.items && typeof schema.items === 'object') {
|
|
216
|
+
if (!schema.type) schema.type = 'array';
|
|
217
|
+
_normalizeSchema(schema.items);
|
|
218
|
+
}
|
|
219
|
+
if (!schema.type) schema.type = isRoot ? 'object' : 'string';
|
|
220
|
+
if (isRoot && schema.type === 'object' && !schema.properties) schema.properties = {};
|
|
221
|
+
return schema;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Which of these property names the page's own markup marks required. */
|
|
225
|
+
function _domRequired(propNames: string[]): string[] {
|
|
226
|
+
const out: string[] = [];
|
|
227
|
+
for (const name of propNames) {
|
|
228
|
+
let el: Element | null = null;
|
|
229
|
+
try {
|
|
230
|
+
el = document.querySelector(`[name="${CSS.escape(name)}"]`);
|
|
231
|
+
} catch {
|
|
232
|
+
// Not a usable selector.
|
|
233
|
+
}
|
|
234
|
+
if (el && ((el as HTMLInputElement).required || el.hasAttribute('data-required'))) out.push(name);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Correct a value's casing to match its schema's enum, when one matches. */
|
|
240
|
+
function _snapEnum(value: unknown, propSchema: JsonSchema | undefined): unknown {
|
|
241
|
+
const enumVals = propSchema && (propSchema.enum || (propSchema.items && propSchema.items.enum));
|
|
242
|
+
if (!enumVals || typeof value !== 'string' || enumVals.includes(value)) return value;
|
|
243
|
+
const ci = enumVals.find((e) => String(e).toLowerCase() === value.toLowerCase());
|
|
244
|
+
return ci !== undefined ? ci : value;
|
|
245
|
+
}
|