@agent-native/core 0.209.0-nightly-20261014153330 → 0.209.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/dist/agent/production-agent.d.ts +8 -0
- package/dist/agent/production-agent.js +68 -3
- package/dist/agent/strict-tool-input.d.ts +4 -0
- package/dist/agent/strict-tool-input.js +26 -0
- package/dist/agent/types.d.ts +7 -0
- package/dist/automation/index.d.ts +1 -1
- package/dist/cli/skills-content/turn-into-app-attachments.d.ts +1 -1
- package/dist/cli/skills-content/turn-into-app-attachments.js +1 -3
- package/dist/cli/skills-content/turn-into-app-skill.d.ts +2 -2
- package/dist/cli/skills-content/turn-into-app-skill.js +15 -10
- package/dist/client/chat/agentkit-protocol.js +2 -0
- package/dist/client/chat/runtime.d.ts +1 -0
- package/dist/client/chat/runtime.js +35 -3
- package/dist/collab/routes.d.ts +1 -1
- package/dist/jobs/actions/manage-recurring-job.d.ts +1 -1
- package/dist/observability/metrics.d.ts +2 -0
- package/dist/observability/metrics.js +15 -1
- package/dist/observability/routes.d.ts +7 -7
- package/dist/observability/traces.js +8 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
- package/dist/provider-api/actions/provider-api.d.ts +10 -10
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +10 -10
- package/dist/server/agent-chat/additional-actions.d.ts +45 -0
- package/dist/server/agent-chat/additional-actions.js +707 -0
- package/dist/server/agent-chat/plugin-options.d.ts +21 -0
- package/dist/server/agent-chat-plugin.js +54 -13
- package/dist/server/better-auth-instance.js +108 -18
- package/dist/server/builder-browser.d.ts +4 -2
- package/dist/server/builder-browser.js +21 -16
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +1 -1
- package/dist/shared/data-url.d.ts +2 -0
- package/dist/shared/data-url.js +12 -0
- package/dist/templates/workspace-core/.agents/skills/turn-into-app/references/attachments.md +1 -3
- package/dist/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +13 -8
- package/dist/templates/workspace-core/.agents/skills/turn-into-app/references/source-brief.md +2 -2
- package/dist/triggers/actions/manage-automation.d.ts +5 -5
- package/docs/content/agent-surfaces.mdx +61 -0
- package/docs/content/observability.mdx +2 -0
- package/package.json +5 -4
- package/src/templates/workspace-core/.agents/skills/turn-into-app/references/attachments.md +1 -3
- package/src/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +13 -8
- package/src/templates/workspace-core/.agents/skills/turn-into-app/references/source-brief.md +2 -2
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
3
|
+
import { registerStrictToolInput } from "../../agent/strict-tool-input.js";
|
|
4
|
+
import { assertRegisteredActionAccess } from "../../authorization/action-access-runtime.js";
|
|
5
|
+
import { normalizeMcpPrincipal } from "../../mcp-client/principal.js";
|
|
6
|
+
import { isMcpToolAllowedForRequest } from "../../mcp-client/visibility.js";
|
|
7
|
+
import { ForbiddenError } from "../../sharing/access.js";
|
|
8
|
+
import { getRequestOrgId, getRequestUserEmail } from "../request-context.js";
|
|
9
|
+
const EXTERNAL_SURFACES = ["a2a", "mcp"];
|
|
10
|
+
// The shape every model provider accepts for a tool name.
|
|
11
|
+
const ACTION_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
12
|
+
const MAX_TITLE_CHARS = 120;
|
|
13
|
+
const MAX_DESCRIPTION_CHARS = 2_000;
|
|
14
|
+
const MAX_SCHEMA_JSON_CHARS = 64_000;
|
|
15
|
+
const MAX_SCHEMA_DEPTH = 24;
|
|
16
|
+
// Every entry is compiled on every request, so the whole registry is bounded,
|
|
17
|
+
// not just each schema.
|
|
18
|
+
const MAX_ADDITIONAL_ACTIONS = 128;
|
|
19
|
+
const MAX_TOTAL_SCHEMA_JSON_CHARS = 512_000;
|
|
20
|
+
const MAX_REPORTED_SKIPS = 1_000;
|
|
21
|
+
// Validation can evaluate a shared `$ref` target once per use, so the cap is on
|
|
22
|
+
// subschemas after expansion, not on the schema's written size.
|
|
23
|
+
const MAX_SCHEMA_EXPANDED_NODES = 5_000;
|
|
24
|
+
const MAX_TOTAL_SCHEMA_EXPANDED_NODES = 20_000;
|
|
25
|
+
// Without recursion, validation evaluates each expanded subschema at most once
|
|
26
|
+
// per input value, so a call is refused before Ajv runs when the product of the
|
|
27
|
+
// two passes this. Ajv validates synchronously; nothing can interrupt it.
|
|
28
|
+
const MAX_VALIDATION_WORK = 20_000_000;
|
|
29
|
+
// Every length check scans the whole string, so a few characters cost as much
|
|
30
|
+
// as one value.
|
|
31
|
+
const INPUT_STRING_CHARS_PER_UNIT = 4;
|
|
32
|
+
// C0/C1 controls other than tab and newline, zero-width characters, and bidi
|
|
33
|
+
// overrides: invisible in logs and tool lists, but read by the model.
|
|
34
|
+
const HIDDEN_TEXT_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F---]/g;
|
|
35
|
+
// Formats are annotations here: no format validators are installed.
|
|
36
|
+
const strictAjvOptions = {
|
|
37
|
+
strict: false,
|
|
38
|
+
allErrors: true,
|
|
39
|
+
coerceTypes: false,
|
|
40
|
+
useDefaults: false,
|
|
41
|
+
removeAdditional: false,
|
|
42
|
+
validateFormats: false,
|
|
43
|
+
};
|
|
44
|
+
const strictAjv = new Ajv(strictAjvOptions);
|
|
45
|
+
const strictAjv2020 = new Ajv2020(strictAjvOptions);
|
|
46
|
+
// A cached validator's own instance skips the meta-schemas, which cost about
|
|
47
|
+
// 30 ms to set up per instance; the shared instances above check each schema
|
|
48
|
+
// against its meta-schema instead, which retains nothing per schema.
|
|
49
|
+
const compileAjvOptions = {
|
|
50
|
+
...strictAjvOptions,
|
|
51
|
+
meta: false,
|
|
52
|
+
validateSchema: false,
|
|
53
|
+
};
|
|
54
|
+
const validateAnyObject = strictAjv.compile({ type: "object" });
|
|
55
|
+
// allErrors reports every failure; the model needs the first few.
|
|
56
|
+
const MAX_REPORTED_INPUT_ERRORS = 10;
|
|
57
|
+
// Compiling is synchronous and costs about as much as the schema is large, and
|
|
58
|
+
// a caller's tools rarely change between runs, so validators are reused by
|
|
59
|
+
// checked schema text. Each is compiled in its own Ajv instance, because Ajv
|
|
60
|
+
// keeps every schema it compiles in instance-wide state; evicting an entry
|
|
61
|
+
// frees it. The cache is bounded by expanded subschemas and by entries.
|
|
62
|
+
const MAX_CACHED_VALIDATOR_NODES = 60_000;
|
|
63
|
+
const MAX_CACHED_VALIDATORS = 256;
|
|
64
|
+
const validatorCache = new Map();
|
|
65
|
+
let cachedValidatorNodes = 0;
|
|
66
|
+
// The keywords app schemas may use, by the shape of their value. Every other
|
|
67
|
+
// key in a schema is refused by name rather than ignored. Ajv's identifier scan
|
|
68
|
+
// walks every key it does not know, so an unknown key can hide a `$id` that
|
|
69
|
+
// rebases `$ref`s onto parts of the schema nobody checked. Keywords left out on
|
|
70
|
+
// purpose: `pattern`/`patternProperties` (regexes run synchronously),
|
|
71
|
+
// `uniqueItems` (quadratic in the input), `$async` (validation that returns a
|
|
72
|
+
// promise), identifiers and anchors (registered in Ajv's shared state), and
|
|
73
|
+
// the `unevaluated*` and `dependent*` keywords.
|
|
74
|
+
const SCHEMA_KEYWORDS = new Set([
|
|
75
|
+
"additionalItems",
|
|
76
|
+
"additionalProperties",
|
|
77
|
+
"contains",
|
|
78
|
+
"else",
|
|
79
|
+
"if",
|
|
80
|
+
"items",
|
|
81
|
+
"not",
|
|
82
|
+
"propertyNames",
|
|
83
|
+
"then",
|
|
84
|
+
]);
|
|
85
|
+
const SCHEMA_LIST_KEYWORDS = new Set([
|
|
86
|
+
"allOf",
|
|
87
|
+
"anyOf",
|
|
88
|
+
"items",
|
|
89
|
+
"oneOf",
|
|
90
|
+
"prefixItems",
|
|
91
|
+
]);
|
|
92
|
+
const SCHEMA_MAP_KEYWORDS = new Set([
|
|
93
|
+
"$defs",
|
|
94
|
+
"definitions",
|
|
95
|
+
"dependencies",
|
|
96
|
+
"properties",
|
|
97
|
+
]);
|
|
98
|
+
const VALUE_KEYWORDS = new Set([
|
|
99
|
+
"$comment",
|
|
100
|
+
"$ref",
|
|
101
|
+
"$schema",
|
|
102
|
+
"const",
|
|
103
|
+
"contentEncoding",
|
|
104
|
+
"contentMediaType",
|
|
105
|
+
"default",
|
|
106
|
+
"deprecated",
|
|
107
|
+
"description",
|
|
108
|
+
"enum",
|
|
109
|
+
"examples",
|
|
110
|
+
"exclusiveMaximum",
|
|
111
|
+
"exclusiveMinimum",
|
|
112
|
+
"format",
|
|
113
|
+
"maxContains",
|
|
114
|
+
"maxItems",
|
|
115
|
+
"maxLength",
|
|
116
|
+
"maxProperties",
|
|
117
|
+
"maximum",
|
|
118
|
+
"minContains",
|
|
119
|
+
"minItems",
|
|
120
|
+
"minLength",
|
|
121
|
+
"minProperties",
|
|
122
|
+
"minimum",
|
|
123
|
+
"multipleOf",
|
|
124
|
+
"readOnly",
|
|
125
|
+
"required",
|
|
126
|
+
"title",
|
|
127
|
+
"type",
|
|
128
|
+
"writeOnly",
|
|
129
|
+
]);
|
|
130
|
+
// Each dialect ignores the other's array keywords instead of enforcing them:
|
|
131
|
+
// schemas without a 2020-12 `$schema` compile as draft-07.
|
|
132
|
+
const DRAFT_2020_ONLY_KEYWORDS = new Set([
|
|
133
|
+
"maxContains",
|
|
134
|
+
"minContains",
|
|
135
|
+
"prefixItems",
|
|
136
|
+
]);
|
|
137
|
+
const DRAFT_07_ONLY_KEYWORDS = new Set(["additionalItems"]);
|
|
138
|
+
const DRAFT_2020_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
139
|
+
// Insertion-ordered, so the oldest key is evicted first once the cap is hit.
|
|
140
|
+
const reportedSkips = new Set();
|
|
141
|
+
export function normalizeAdditionalActionSurfaces(surfaces) {
|
|
142
|
+
if (surfaces === undefined)
|
|
143
|
+
return [];
|
|
144
|
+
if (!Array.isArray(surfaces)) {
|
|
145
|
+
throw new TypeError("additionalActionSurfaces must be an array");
|
|
146
|
+
}
|
|
147
|
+
const normalized = new Set();
|
|
148
|
+
for (const surface of surfaces) {
|
|
149
|
+
if (!EXTERNAL_SURFACES.includes(surface)) {
|
|
150
|
+
throw new TypeError(`additionalActionSurfaces contains "${String(surface)}"; expected "a2a" or "mcp". Interactive chat always receives additional actions.`);
|
|
151
|
+
}
|
|
152
|
+
normalized.add(surface);
|
|
153
|
+
}
|
|
154
|
+
return [...normalized];
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Runs the app's `resolveAdditionalActions` once for one request and returns
|
|
158
|
+
* the entries that may join that request's registry.
|
|
159
|
+
*
|
|
160
|
+
* Everything the app returns is untrusted: it may carry names, descriptions,
|
|
161
|
+
* and schemas a user registered. An entry is dropped (and reported once) when
|
|
162
|
+
* its name is malformed or reserved, when it is an `mcp__` name this caller
|
|
163
|
+
* cannot see, or when its tool definition is unusable. Each kept entry is bound
|
|
164
|
+
* to the caller it was resolved for and its `run` is wrapped so every call,
|
|
165
|
+
* from any dispatcher, rechecks that caller, enforces the entry's `access`
|
|
166
|
+
* policy, and validates the arguments against its schema without coercion.
|
|
167
|
+
*/
|
|
168
|
+
export async function resolveAppAdditionalActions(config, details, registered) {
|
|
169
|
+
const { resolver } = config;
|
|
170
|
+
const { ownerEmail, surface } = details;
|
|
171
|
+
// Anonymous callers get no app actions, by the same rule as MCP tools.
|
|
172
|
+
if (!resolver ||
|
|
173
|
+
!ownerEmail ||
|
|
174
|
+
!normalizeMcpPrincipal({ userEmail: ownerEmail, orgId: details.orgId })) {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
if (surface !== "chat" && !config.externalSurfaces.includes(surface)) {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
const principal = { ownerEmail, orgId: details.orgId ?? null };
|
|
181
|
+
const entries = await resolver({
|
|
182
|
+
surface,
|
|
183
|
+
event: details.event,
|
|
184
|
+
...principal,
|
|
185
|
+
});
|
|
186
|
+
if (!isPlainRecord(entries)) {
|
|
187
|
+
throw new TypeError("resolveAdditionalActions must return an object of action entries");
|
|
188
|
+
}
|
|
189
|
+
const names = Object.keys(entries);
|
|
190
|
+
if (names.length > MAX_ADDITIONAL_ACTIONS) {
|
|
191
|
+
reportSkip(names[MAX_ADDITIONAL_ACTIONS], `is the first of ${names.length - MAX_ADDITIONAL_ACTIONS} entries past the ${MAX_ADDITIONAL_ACTIONS} a resolver may return`);
|
|
192
|
+
}
|
|
193
|
+
const added = {};
|
|
194
|
+
const schemaBudget = {
|
|
195
|
+
remaining: MAX_TOTAL_SCHEMA_JSON_CHARS,
|
|
196
|
+
expandedRemaining: MAX_TOTAL_SCHEMA_EXPANDED_NODES,
|
|
197
|
+
};
|
|
198
|
+
for (const name of names.slice(0, MAX_ADDITIONAL_ACTIONS)) {
|
|
199
|
+
const entry = entries[name];
|
|
200
|
+
const rejection = rejectActionName(name, config, registered) ?? rejectActionEntry(entry);
|
|
201
|
+
if (rejection) {
|
|
202
|
+
reportSkip(name, rejection);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const prepared = prepareActionEntry(name, entry, principal, schemaBudget);
|
|
206
|
+
if (typeof prepared === "string") {
|
|
207
|
+
reportSkip(name, prepared);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
added[name] = prepared;
|
|
211
|
+
}
|
|
212
|
+
return added;
|
|
213
|
+
}
|
|
214
|
+
function rejectActionName(name, config, registered) {
|
|
215
|
+
if (!ACTION_NAME_PATTERN.test(name)) {
|
|
216
|
+
return "is not a valid action name (letters, digits, '_' and '-', at most 64 characters)";
|
|
217
|
+
}
|
|
218
|
+
// Assigning it to a registry object sets the prototype instead of a key.
|
|
219
|
+
if (name === "__proto__")
|
|
220
|
+
return "is not a valid action name";
|
|
221
|
+
if (Object.hasOwn(registered, name) || config.isReservedName(name)) {
|
|
222
|
+
return "is a registered action name";
|
|
223
|
+
}
|
|
224
|
+
if (name.startsWith("mcp__") && !isMcpToolAllowedForRequest(name)) {
|
|
225
|
+
return "is an MCP tool name this caller cannot use";
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
function rejectActionEntry(entry) {
|
|
230
|
+
if (!isPlainRecord(entry))
|
|
231
|
+
return "is not an action entry";
|
|
232
|
+
if (typeof entry.run !== "function")
|
|
233
|
+
return "has no run function";
|
|
234
|
+
if (!isPlainRecord(entry.tool))
|
|
235
|
+
return "has no tool definition";
|
|
236
|
+
if (typeof entry.tool.description !== "string") {
|
|
237
|
+
return "has no tool description";
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
function prepareActionEntry(name, entry, principal, schemaBudget) {
|
|
242
|
+
// Read each field once: a getter could return one value to the checks and
|
|
243
|
+
// another to the copy the model and dispatcher use.
|
|
244
|
+
const { title: rawTitle, description: rawDescription, parameters: rawParameters, ...tool } = entry.tool;
|
|
245
|
+
if (typeof rawDescription !== "string")
|
|
246
|
+
return "has no tool description";
|
|
247
|
+
const description = cleanText(rawDescription, MAX_DESCRIPTION_CHARS);
|
|
248
|
+
if (!description)
|
|
249
|
+
return "has an empty tool description";
|
|
250
|
+
const title = typeof rawTitle === "string"
|
|
251
|
+
? cleanText(rawTitle, MAX_TITLE_CHARS)
|
|
252
|
+
: undefined;
|
|
253
|
+
let validate = validateAnyObject;
|
|
254
|
+
let expandedSize = 1;
|
|
255
|
+
let parameters;
|
|
256
|
+
if (rawParameters !== undefined) {
|
|
257
|
+
const checked = checkParameterSchema(rawParameters, schemaBudget);
|
|
258
|
+
if (typeof checked === "string")
|
|
259
|
+
return checked;
|
|
260
|
+
({ schema: parameters, expanded: expandedSize } = checked);
|
|
261
|
+
try {
|
|
262
|
+
validate = compileStrict(parameters, expandedSize);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return `has a tool schema that does not compile: ${error instanceof Error ? error.message : String(error)}`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const run = entry.run;
|
|
269
|
+
const access = entry.access;
|
|
270
|
+
const maxInputUnits = Math.floor(MAX_VALIDATION_WORK / expandedSize);
|
|
271
|
+
const inputError = (input) => {
|
|
272
|
+
if (!inputFitsUnits(input, maxInputUnits)) {
|
|
273
|
+
return `arguments are too large to validate against this tool's schema; send less data`;
|
|
274
|
+
}
|
|
275
|
+
if (validate(input))
|
|
276
|
+
return null;
|
|
277
|
+
const errors = validate.errors ?? [];
|
|
278
|
+
const shown = strictAjv.errorsText(errors.slice(0, MAX_REPORTED_INPUT_ERRORS), { dataVar: "input", separator: "; " });
|
|
279
|
+
return errors.length > MAX_REPORTED_INPUT_ERRORS
|
|
280
|
+
? `${shown}; and ${errors.length - MAX_REPORTED_INPUT_ERRORS} more`
|
|
281
|
+
: shown;
|
|
282
|
+
};
|
|
283
|
+
const prepared = {
|
|
284
|
+
...entry,
|
|
285
|
+
// The model sees exactly the schema that was checked and compiled.
|
|
286
|
+
tool: {
|
|
287
|
+
...tool,
|
|
288
|
+
...(parameters
|
|
289
|
+
? {
|
|
290
|
+
parameters: parameters,
|
|
291
|
+
}
|
|
292
|
+
: {}),
|
|
293
|
+
...(title ? { title } : {}),
|
|
294
|
+
description,
|
|
295
|
+
},
|
|
296
|
+
run: async (args, ctx) => {
|
|
297
|
+
const caller = assertBoundCaller(name, principal, ctx);
|
|
298
|
+
const input = args === undefined ? {} : args;
|
|
299
|
+
const error = inputError(input);
|
|
300
|
+
if (error) {
|
|
301
|
+
throw new TypeError(`Invalid arguments for ${name}: ${error}`);
|
|
302
|
+
}
|
|
303
|
+
if (access) {
|
|
304
|
+
await assertRegisteredActionAccess(access, input, {
|
|
305
|
+
...ctx,
|
|
306
|
+
caller: ctx?.caller ?? "tool",
|
|
307
|
+
userEmail: caller.userEmail,
|
|
308
|
+
orgId: caller.orgId,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return run(input, ctx);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
// The agent loop would otherwise repair and coerce the model's input before
|
|
315
|
+
// `run` sees it, so a string "3" would reach a number parameter as 3.
|
|
316
|
+
registerStrictToolInput(prepared, (input) => inputError(input === undefined ? {} : input));
|
|
317
|
+
return prepared;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The entry was resolved for one caller. Whatever dispatcher reaches it — the
|
|
321
|
+
* agent loop, tool orchestration, run-code, a sub-agent — must be running for
|
|
322
|
+
* that same caller, so an entry that escapes its request still cannot act for
|
|
323
|
+
* anyone else.
|
|
324
|
+
*/
|
|
325
|
+
function assertBoundCaller(name, principal, ctx) {
|
|
326
|
+
const userEmail = ctx?.userEmail ?? getRequestUserEmail();
|
|
327
|
+
const orgId = ctx && Object.hasOwn(ctx, "orgId")
|
|
328
|
+
? (ctx.orgId ?? null)
|
|
329
|
+
: (getRequestOrgId() ?? null);
|
|
330
|
+
if (!userEmail ||
|
|
331
|
+
userEmail.trim().toLowerCase() !==
|
|
332
|
+
principal.ownerEmail.trim().toLowerCase() ||
|
|
333
|
+
orgId !== principal.orgId ||
|
|
334
|
+
(name.startsWith("mcp__") && !isMcpToolAllowedForRequest(name))) {
|
|
335
|
+
throw new ForbiddenError(`${name} is not available to this caller.`);
|
|
336
|
+
}
|
|
337
|
+
return { userEmail, orgId };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Copies the app's schema into plain JSON data, checks it, and returns the copy
|
|
341
|
+
* with its size after `$ref` expansion. Everything after this point, the
|
|
342
|
+
* keyword check, Ajv, and the tool list the model sees, uses the copy, so a
|
|
343
|
+
* getter, prototype, or proxy in the app's object cannot show the checks one
|
|
344
|
+
* schema and Ajv another.
|
|
345
|
+
*/
|
|
346
|
+
function checkParameterSchema(parameters, schemaBudget) {
|
|
347
|
+
// Both request budgets are charged for the work done, accepted or not, so
|
|
348
|
+
// entries that each fail late cannot each spend a full per-schema budget.
|
|
349
|
+
const limit = Math.min(MAX_SCHEMA_JSON_CHARS, schemaBudget.remaining);
|
|
350
|
+
const copied = copyJsonValue(parameters, limit);
|
|
351
|
+
schemaBudget.remaining -= copied.size;
|
|
352
|
+
if (copied.failure === "too-deep") {
|
|
353
|
+
return `has a tool schema nested deeper than ${MAX_SCHEMA_DEPTH} levels`;
|
|
354
|
+
}
|
|
355
|
+
if (copied.failure === "too-large") {
|
|
356
|
+
return limit < MAX_SCHEMA_JSON_CHARS
|
|
357
|
+
? `has a tool schema that exceeds the ${MAX_TOTAL_SCHEMA_JSON_CHARS}-character budget shared by this request's schemas`
|
|
358
|
+
: `has a tool schema larger than ${MAX_SCHEMA_JSON_CHARS} characters`;
|
|
359
|
+
}
|
|
360
|
+
if (copied.failure) {
|
|
361
|
+
return `has a tool schema that is not plain JSON data (${copied.failure})`;
|
|
362
|
+
}
|
|
363
|
+
const schema = copied.value;
|
|
364
|
+
if (!isPlainRecord(schema))
|
|
365
|
+
return "has a tool schema that is not an object";
|
|
366
|
+
if (schema.type !== "object") {
|
|
367
|
+
return 'has a tool schema whose root type is not "object"';
|
|
368
|
+
}
|
|
369
|
+
if (schema.properties !== undefined && !isPlainRecord(schema.properties)) {
|
|
370
|
+
return "has a tool schema whose properties are not an object";
|
|
371
|
+
}
|
|
372
|
+
const walk = {
|
|
373
|
+
expanded: 0,
|
|
374
|
+
limit: Math.min(MAX_SCHEMA_EXPANDED_NODES, schemaBudget.expandedRemaining),
|
|
375
|
+
refStack: new Set([schema]),
|
|
376
|
+
draft2020: isDraft2020(schema),
|
|
377
|
+
};
|
|
378
|
+
const rejection = rejectSchemaKeywords(schema, schema, walk);
|
|
379
|
+
schemaBudget.expandedRemaining -= walk.expanded;
|
|
380
|
+
if (rejection === "too-expanded") {
|
|
381
|
+
return walk.limit < MAX_SCHEMA_EXPANDED_NODES
|
|
382
|
+
? `has a tool schema that exceeds the ${MAX_TOTAL_SCHEMA_EXPANDED_NODES}-subschema budget shared by this request's schemas`
|
|
383
|
+
: `has a tool schema that expands to more than ${MAX_SCHEMA_EXPANDED_NODES} subschemas through $ref`;
|
|
384
|
+
}
|
|
385
|
+
if (rejection)
|
|
386
|
+
return rejection;
|
|
387
|
+
return { schema, expanded: walk.expanded };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Copies own data properties of plain objects and dense arrays, approximating
|
|
391
|
+
* the JSON length as it goes and stopping as soon as it passes `limit` or
|
|
392
|
+
* `MAX_SCHEMA_DEPTH`, so an oversized or cyclic value costs no more than the
|
|
393
|
+
* budget it is rejected for.
|
|
394
|
+
*/
|
|
395
|
+
function copyJsonValue(value, limit) {
|
|
396
|
+
let size = 0;
|
|
397
|
+
let failure = null;
|
|
398
|
+
const fail = (reason) => {
|
|
399
|
+
failure = reason;
|
|
400
|
+
return undefined;
|
|
401
|
+
};
|
|
402
|
+
const copy = (node, depth) => {
|
|
403
|
+
if (depth > MAX_SCHEMA_DEPTH)
|
|
404
|
+
return fail("too-deep");
|
|
405
|
+
if (node === null || typeof node === "boolean") {
|
|
406
|
+
size += 5;
|
|
407
|
+
return node;
|
|
408
|
+
}
|
|
409
|
+
if (typeof node === "number") {
|
|
410
|
+
if (!Number.isFinite(node))
|
|
411
|
+
return fail("a value that is not JSON");
|
|
412
|
+
size += 24;
|
|
413
|
+
return node;
|
|
414
|
+
}
|
|
415
|
+
if (typeof node === "string") {
|
|
416
|
+
size += node.length + 2;
|
|
417
|
+
return size > limit ? fail("too-large") : node;
|
|
418
|
+
}
|
|
419
|
+
if (typeof node !== "object")
|
|
420
|
+
return fail("a value that is not JSON");
|
|
421
|
+
const prototype = Object.getPrototypeOf(node);
|
|
422
|
+
size += 2;
|
|
423
|
+
if (Array.isArray(node)) {
|
|
424
|
+
if (prototype !== Array.prototype) {
|
|
425
|
+
return fail("an object with a custom prototype");
|
|
426
|
+
}
|
|
427
|
+
const out = [];
|
|
428
|
+
for (let index = 0; index < node.length; index += 1) {
|
|
429
|
+
size += 1;
|
|
430
|
+
if (size > limit)
|
|
431
|
+
return fail("too-large");
|
|
432
|
+
const descriptor = Object.getOwnPropertyDescriptor(node, index);
|
|
433
|
+
if (!descriptor)
|
|
434
|
+
return fail("an array with holes");
|
|
435
|
+
if (!("value" in descriptor))
|
|
436
|
+
return fail("a getter or setter");
|
|
437
|
+
out.push(copy(descriptor.value, depth + 1));
|
|
438
|
+
if (failure)
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
444
|
+
return fail("an object with a custom prototype");
|
|
445
|
+
}
|
|
446
|
+
const out = {};
|
|
447
|
+
for (const key in node) {
|
|
448
|
+
if (!Object.hasOwn(node, key))
|
|
449
|
+
continue;
|
|
450
|
+
if (key === "__proto__")
|
|
451
|
+
return fail("a __proto__ key");
|
|
452
|
+
const descriptor = Object.getOwnPropertyDescriptor(node, key);
|
|
453
|
+
if (!("value" in descriptor))
|
|
454
|
+
return fail("a getter or setter");
|
|
455
|
+
if (descriptor.value === undefined)
|
|
456
|
+
continue;
|
|
457
|
+
size += key.length + 4;
|
|
458
|
+
if (size > limit)
|
|
459
|
+
return fail("too-large");
|
|
460
|
+
out[key] = copy(descriptor.value, depth + 1);
|
|
461
|
+
if (failure)
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
};
|
|
466
|
+
const copied = copy(value, 0);
|
|
467
|
+
return failure ? { size, failure } : { value: copied, size, failure: null };
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Walks every location Ajv evaluates as a schema, refusing keys outside the
|
|
471
|
+
* allowlist and following each local `$ref`.
|
|
472
|
+
*
|
|
473
|
+
* Each `$ref` is expanded where it is used, as validation evaluates it, so a
|
|
474
|
+
* small schema that references the same definition several times per level
|
|
475
|
+
* shows its exponential validation cost here and is rejected. A `$ref` back
|
|
476
|
+
* into a schema it is already inside is refused outright: even when each
|
|
477
|
+
* level steps into a property or item, a recursive schema with more than one
|
|
478
|
+
* reference per level costs validation exponential work in the input's
|
|
479
|
+
* nesting depth, which the expansion count cannot see.
|
|
480
|
+
*/
|
|
481
|
+
function rejectSchemaKeywords(schema, root, walk) {
|
|
482
|
+
// Boolean subschemas are compiled and evaluated too, so they count.
|
|
483
|
+
walk.expanded += 1;
|
|
484
|
+
if (walk.expanded > walk.limit)
|
|
485
|
+
return "too-expanded";
|
|
486
|
+
if (typeof schema === "boolean")
|
|
487
|
+
return null;
|
|
488
|
+
if (!isPlainRecord(schema)) {
|
|
489
|
+
return "has a tool schema with a subschema that is not an object or boolean";
|
|
490
|
+
}
|
|
491
|
+
for (const [keyword, value] of Object.entries(schema)) {
|
|
492
|
+
let rejection = null;
|
|
493
|
+
if (walk.draft2020
|
|
494
|
+
? DRAFT_07_ONLY_KEYWORDS.has(keyword)
|
|
495
|
+
: DRAFT_2020_ONLY_KEYWORDS.has(keyword)) {
|
|
496
|
+
rejection = walk.draft2020
|
|
497
|
+
? `has a tool schema that uses "${keyword}", which draft 2020-12 does not enforce`
|
|
498
|
+
: `has a tool schema that uses "${keyword}" without "$schema": "${DRAFT_2020_DIALECT}", so it would not be enforced`;
|
|
499
|
+
}
|
|
500
|
+
else if (keyword === "items" && Array.isArray(value)) {
|
|
501
|
+
rejection = rejectEach(value, root, walk);
|
|
502
|
+
}
|
|
503
|
+
else if (SCHEMA_KEYWORDS.has(keyword)) {
|
|
504
|
+
rejection = rejectSchemaKeywords(value, root, walk);
|
|
505
|
+
}
|
|
506
|
+
else if (SCHEMA_LIST_KEYWORDS.has(keyword)) {
|
|
507
|
+
rejection = Array.isArray(value)
|
|
508
|
+
? rejectEach(value, root, walk)
|
|
509
|
+
: `has a tool schema whose "${keyword}" is not an array`;
|
|
510
|
+
}
|
|
511
|
+
else if (SCHEMA_MAP_KEYWORDS.has(keyword)) {
|
|
512
|
+
if (!isPlainRecord(value)) {
|
|
513
|
+
rejection = `has a tool schema whose "${keyword}" is not an object`;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
// `dependencies` maps a property to a schema or to required names,
|
|
517
|
+
// which are counted like `required`.
|
|
518
|
+
const children = [];
|
|
519
|
+
for (const child of Object.values(value)) {
|
|
520
|
+
if (keyword === "dependencies" && Array.isArray(child)) {
|
|
521
|
+
walk.expanded += countJsonNodes(child);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
children.push(child);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
rejection =
|
|
528
|
+
walk.expanded > walk.limit
|
|
529
|
+
? "too-expanded"
|
|
530
|
+
: rejectEach(children, root, walk);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
else if (keyword === "$ref") {
|
|
534
|
+
rejection = rejectRef(value, root, walk);
|
|
535
|
+
}
|
|
536
|
+
else if (keyword === "$schema" && schema !== root) {
|
|
537
|
+
rejection = 'has a tool schema that sets "$schema" below its root';
|
|
538
|
+
}
|
|
539
|
+
else if (keyword === "enum" ||
|
|
540
|
+
keyword === "const" ||
|
|
541
|
+
keyword === "required") {
|
|
542
|
+
// Each listed value is compared against, or looked up in, the input.
|
|
543
|
+
walk.expanded += countJsonNodes(value);
|
|
544
|
+
if (walk.expanded > walk.limit)
|
|
545
|
+
return "too-expanded";
|
|
546
|
+
}
|
|
547
|
+
else if (!VALUE_KEYWORDS.has(keyword)) {
|
|
548
|
+
rejection = `has a tool schema that uses "${keyword.slice(0, 80)}", which app actions may not use`;
|
|
549
|
+
}
|
|
550
|
+
if (rejection)
|
|
551
|
+
return rejection;
|
|
552
|
+
}
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
function rejectEach(schemas, root, walk) {
|
|
556
|
+
for (const child of schemas) {
|
|
557
|
+
const rejection = rejectSchemaKeywords(child, root, walk);
|
|
558
|
+
if (rejection)
|
|
559
|
+
return rejection;
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
function rejectRef(ref, root, walk) {
|
|
564
|
+
const target = resolveLocalRef(root, ref);
|
|
565
|
+
if (target === undefined) {
|
|
566
|
+
return 'has a tool schema with a $ref that is not "#" or an unencoded JSON pointer inside the schema';
|
|
567
|
+
}
|
|
568
|
+
// Only objects can recurse, but every target, boolean ones included, is
|
|
569
|
+
// evaluated where the $ref is used and counts toward the budget.
|
|
570
|
+
const recursive = isPlainRecord(target);
|
|
571
|
+
if (recursive && walk.refStack.has(target)) {
|
|
572
|
+
return "has a tool schema with a recursive $ref";
|
|
573
|
+
}
|
|
574
|
+
if (recursive)
|
|
575
|
+
walk.refStack.add(target);
|
|
576
|
+
const rejection = rejectSchemaKeywords(target, root, walk);
|
|
577
|
+
if (recursive)
|
|
578
|
+
walk.refStack.delete(target);
|
|
579
|
+
return rejection;
|
|
580
|
+
}
|
|
581
|
+
function countJsonNodes(value) {
|
|
582
|
+
if (!value || typeof value !== "object")
|
|
583
|
+
return 1;
|
|
584
|
+
let count = 1;
|
|
585
|
+
for (const child of Object.values(value))
|
|
586
|
+
count += countJsonNodes(child);
|
|
587
|
+
return count;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Ajv percent-decodes the whole fragment before splitting it into pointer
|
|
591
|
+
* segments, so an encoded `/` addresses a different location than a
|
|
592
|
+
* segment-by-segment reading would. Encoded refs are refused rather than
|
|
593
|
+
* reimplementing that decoding.
|
|
594
|
+
*/
|
|
595
|
+
function resolveLocalRef(root, ref) {
|
|
596
|
+
if (ref === "#")
|
|
597
|
+
return root;
|
|
598
|
+
if (typeof ref !== "string" || !ref.startsWith("#/") || ref.includes("%")) {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
let target = root;
|
|
602
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
603
|
+
const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
604
|
+
if (!target ||
|
|
605
|
+
typeof target !== "object" ||
|
|
606
|
+
!Object.hasOwn(target, segment)) {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
target = target[segment];
|
|
610
|
+
}
|
|
611
|
+
return target;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Counts the input's values and object keys (long strings count once per
|
|
615
|
+
* `INPUT_STRING_CHARS_PER_UNIT` characters; `propertyNames` checks keys like
|
|
616
|
+
* strings) and stops at `maxUnits`. The walk is iterative because the model
|
|
617
|
+
* controls how deeply its arguments nest.
|
|
618
|
+
*/
|
|
619
|
+
function inputFitsUnits(input, maxUnits) {
|
|
620
|
+
let units = 0;
|
|
621
|
+
const pending = [input];
|
|
622
|
+
while (pending.length > 0) {
|
|
623
|
+
const node = pending.pop();
|
|
624
|
+
units +=
|
|
625
|
+
typeof node === "string"
|
|
626
|
+
? 1 + Math.floor(node.length / INPUT_STRING_CHARS_PER_UNIT)
|
|
627
|
+
: 1;
|
|
628
|
+
if (units > maxUnits)
|
|
629
|
+
return false;
|
|
630
|
+
// Every queued node costs at least one unit, so stop queuing as soon as
|
|
631
|
+
// the queue alone is over budget rather than after copying a huge node.
|
|
632
|
+
if (Array.isArray(node)) {
|
|
633
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
634
|
+
pending.push(node[i]);
|
|
635
|
+
if (units + pending.length > maxUnits)
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
else if (node && typeof node === "object") {
|
|
640
|
+
for (const key in node) {
|
|
641
|
+
if (!Object.hasOwn(node, key))
|
|
642
|
+
continue;
|
|
643
|
+
pending.push(key, node[key]);
|
|
644
|
+
if (units + pending.length > maxUnits)
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
function compileStrict(schema, expanded) {
|
|
652
|
+
const is2020 = isDraft2020(schema);
|
|
653
|
+
const key = `${is2020 ? "2020" : "7"}${JSON.stringify(schema)}`;
|
|
654
|
+
const cached = validatorCache.get(key);
|
|
655
|
+
if (cached) {
|
|
656
|
+
validatorCache.delete(key);
|
|
657
|
+
validatorCache.set(key, cached);
|
|
658
|
+
return cached.validate;
|
|
659
|
+
}
|
|
660
|
+
const metaAjv = is2020 ? strictAjv2020 : strictAjv;
|
|
661
|
+
if (!metaAjv.validateSchema(schema)) {
|
|
662
|
+
throw new Error(`schema is invalid: ${metaAjv.errorsText(metaAjv.errors)}`);
|
|
663
|
+
}
|
|
664
|
+
const ajv = is2020
|
|
665
|
+
? new Ajv2020(compileAjvOptions)
|
|
666
|
+
: new Ajv(compileAjvOptions);
|
|
667
|
+
const validate = ajv.compile(schema);
|
|
668
|
+
if (validate.$async) {
|
|
669
|
+
throw new Error("the schema validates asynchronously");
|
|
670
|
+
}
|
|
671
|
+
validatorCache.set(key, { validate, expanded });
|
|
672
|
+
cachedValidatorNodes += expanded;
|
|
673
|
+
for (const [oldest, entry] of validatorCache) {
|
|
674
|
+
if (cachedValidatorNodes <= MAX_CACHED_VALIDATOR_NODES &&
|
|
675
|
+
validatorCache.size <= MAX_CACHED_VALIDATORS) {
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
validatorCache.delete(oldest);
|
|
679
|
+
cachedValidatorNodes -= entry.expanded;
|
|
680
|
+
}
|
|
681
|
+
return validate;
|
|
682
|
+
}
|
|
683
|
+
function isDraft2020(schema) {
|
|
684
|
+
const declared = schema.$schema;
|
|
685
|
+
return (typeof declared === "string" &&
|
|
686
|
+
declared.replace(/#$/, "") === DRAFT_2020_DIALECT);
|
|
687
|
+
}
|
|
688
|
+
function cleanText(value, maxChars) {
|
|
689
|
+
const cleaned = value.replace(HIDDEN_TEXT_PATTERN, "").trim();
|
|
690
|
+
return cleaned.length > maxChars
|
|
691
|
+
? `${cleaned.slice(0, maxChars - 1)}…`
|
|
692
|
+
: cleaned;
|
|
693
|
+
}
|
|
694
|
+
function isPlainRecord(value) {
|
|
695
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
696
|
+
}
|
|
697
|
+
function reportSkip(name, reason) {
|
|
698
|
+
const shown = JSON.stringify(name.slice(0, 80));
|
|
699
|
+
const key = `${shown}\u0000${reason.slice(0, 200)}`;
|
|
700
|
+
if (reportedSkips.has(key))
|
|
701
|
+
return;
|
|
702
|
+
if (reportedSkips.size >= MAX_REPORTED_SKIPS) {
|
|
703
|
+
reportedSkips.delete(reportedSkips.values().next().value);
|
|
704
|
+
}
|
|
705
|
+
reportedSkips.add(key);
|
|
706
|
+
console.warn(`[agent-chat] resolveAdditionalActions returned ${shown}, which ${reason}; ignoring the app entry.`);
|
|
707
|
+
}
|