@jini-ai/core 0.4.7 → 0.4.9
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/CHANGELOG.md +11 -0
- package/dist/agent-tools.d.ts +1 -1
- package/dist/approval.d.ts +15 -0
- package/dist/approval.js +87 -22
- package/dist/batch-tool-inputs.d.ts +147 -0
- package/dist/batch-tool-inputs.js +257 -0
- package/dist/feature-flags.d.ts +21 -0
- package/dist/feature-flags.js +17 -0
- package/dist/fetch/index.d.ts +2 -1
- package/dist/fetch/index.js +1 -0
- package/dist/fetch/loader-read.d.ts +16 -0
- package/dist/fetch/loader-read.js +22 -0
- package/dist/fetch/types.d.ts +12 -2
- package/dist/gated-mutations/gateway.js +32 -27
- package/dist/index.d.ts +9 -1
- package/dist/index.js +8 -1
- package/dist/object-store.d.ts +115 -0
- package/dist/object-store.js +70 -0
- package/dist/origin-validation.js +64 -57
- package/dist/primitives/http.d.ts +2 -2
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/paths.js +17 -10
- package/dist/primitives/tasks.d.ts +10 -0
- package/dist/primitives/tasks.js +8 -0
- package/dist/provider-catalog.d.ts +76 -0
- package/dist/provider-catalog.js +6 -0
- package/dist/provider-registry.d.ts +144 -0
- package/dist/provider-registry.js +138 -0
- package/dist/records.d.ts +24 -0
- package/dist/records.js +25 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +57 -34
- package/dist/registration-kit.d.ts +7 -4
- package/dist/registration-kit.js +81 -62
- package/dist/settings/agent-value-write-tools.d.ts +19 -0
- package/dist/settings/agent-value-write-tools.js +131 -70
- package/dist/settings/ensure-definitions.d.ts +4 -2
- package/dist/settings/ensure-definitions.js +79 -63
- package/dist/settings/express/cms-adapter.d.ts +2 -2
- package/dist/settings/express/cms-adapter.js +34 -22
- package/dist/settings/express/http-request.d.ts +33 -0
- package/dist/settings/express/http-request.js +49 -0
- package/dist/settings/express/http.d.ts +2 -2
- package/dist/settings/express/http.js +16 -39
- package/dist/settings/express/stream.d.ts +2 -2
- package/dist/settings/express/stream.js +49 -27
- package/dist/settings/repo.memory.js +7 -4
- package/dist/settings/settings.js +54 -60
- package/dist/settings/tool-registrations.d.ts +2 -3
- package/dist/settings/tool-registrations.js +64 -39
- package/dist/settings/write-service.d.ts +19 -0
- package/dist/settings/write-service.js +34 -0
- package/dist/target-scope.d.ts +52 -0
- package/dist/target-scope.js +82 -0
- package/dist/text/escape.js +2 -2
- package/dist/text/sanitize-untrusted-text.d.ts +5 -0
- package/dist/text/sanitize-untrusted-text.js +55 -35
- package/dist/tool-exposure.d.ts +79 -0
- package/dist/tool-exposure.js +163 -0
- package/dist/tool-input-errors.d.ts +23 -0
- package/dist/tool-input-errors.js +24 -0
- package/dist/tool-input-normalization.d.ts +37 -0
- package/dist/tool-input-normalization.js +188 -0
- package/dist/tool-metadata-types.d.ts +26 -0
- package/dist/tool-metadata-types.js +2 -0
- package/dist/tool-metadata.d.ts +2 -23
- package/dist/tool-registry.d.ts +38 -22
- package/dist/tool-registry.js +35 -20
- package/package.json +20 -2
- package/plugin-directory.js +72 -33
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LABELED_SECRET_RE, OPAQUE_TOKEN_RE, NON_SECRET_TOKEN_RE } from "../redact.js";
|
|
1
|
+
import { redactSecrets, LABELED_SECRET_RE, OPAQUE_TOKEN_RE, NON_SECRET_TOKEN_RE } from "../redact.js";
|
|
2
2
|
/**
|
|
3
3
|
* @module redact
|
|
4
4
|
*
|
|
@@ -39,6 +39,38 @@ function isOtherControlCode(code) {
|
|
|
39
39
|
return true; // C1 controls
|
|
40
40
|
return false;
|
|
41
41
|
}
|
|
42
|
+
/** True when position `j` starts an OSC terminator: BEL, or ST (`ESC \\`). */
|
|
43
|
+
function isOscTerminatorAt(text, j) {
|
|
44
|
+
const code = text.charCodeAt(j);
|
|
45
|
+
return code === BEL || (code === ESC && text.charCodeAt(j + 1) === BACKSLASH);
|
|
46
|
+
}
|
|
47
|
+
/** Index of a CSI's final byte at or after `from`, or `text.length` when the CSI is truncated. */
|
|
48
|
+
function csiEnd(text, from) {
|
|
49
|
+
let j = from;
|
|
50
|
+
while (j < text.length && !isCsiFinalByte(text.charCodeAt(j)))
|
|
51
|
+
j++;
|
|
52
|
+
return j;
|
|
53
|
+
}
|
|
54
|
+
/** Index of the last code unit of an OSC body starting at `from`: the BEL, the `\\` of ST, or the
|
|
55
|
+
* final code unit when the OSC is never terminated. */
|
|
56
|
+
function oscEnd(text, from) {
|
|
57
|
+
let j = from;
|
|
58
|
+
while (j < text.length && !isOscTerminatorAt(text, j))
|
|
59
|
+
j++;
|
|
60
|
+
// charCodeAt past the end is NaN, so a truncated OSC never matches BEL here.
|
|
61
|
+
if (text.charCodeAt(j) === BEL)
|
|
62
|
+
return j;
|
|
63
|
+
return Math.min(j + 1, text.length - 1);
|
|
64
|
+
}
|
|
65
|
+
/** Index of the last code unit consumed by the escape sequence whose ESC sits at `start`. */
|
|
66
|
+
function escapeSequenceEnd(text, start) {
|
|
67
|
+
const next = text.charCodeAt(start + 1);
|
|
68
|
+
if (next === CSI_OPEN)
|
|
69
|
+
return csiEnd(text, start + 2);
|
|
70
|
+
if (next === OSC_OPEN)
|
|
71
|
+
return oscEnd(text, start + 2);
|
|
72
|
+
return start; // bare ESC (or an unrecognized ESC-prefixed byte): drop just the ESC byte
|
|
73
|
+
}
|
|
42
74
|
/**
|
|
43
75
|
* Strip ANSI/terminal escape sequences (CSI `ESC [ ... final-byte`, OSC `ESC ] ... BEL-or-ST`,
|
|
44
76
|
* and bare ESC-prefixed bytes) plus other C0/C1 control characters from `text`. Written as an
|
|
@@ -50,53 +82,41 @@ export function stripControlSequences({ text }) {
|
|
|
50
82
|
for (let i = 0; i < text.length; i++) {
|
|
51
83
|
const code = text.charCodeAt(i);
|
|
52
84
|
if (code === ESC) {
|
|
53
|
-
|
|
54
|
-
if (next === CSI_OPEN) {
|
|
55
|
-
let j = i + 2;
|
|
56
|
-
while (j < text.length && !isCsiFinalByte(text.charCodeAt(j)))
|
|
57
|
-
j++;
|
|
58
|
-
i = j; // land on the final byte; the outer loop's i++ steps past it
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (next === OSC_OPEN) {
|
|
62
|
-
let j = i + 2;
|
|
63
|
-
while (j < text.length &&
|
|
64
|
-
text.charCodeAt(j) !== BEL &&
|
|
65
|
-
!(text.charCodeAt(j) === ESC && text.charCodeAt(j + 1) === BACKSLASH)) {
|
|
66
|
-
j++;
|
|
67
|
-
}
|
|
68
|
-
if (j < text.length && text.charCodeAt(j) === BEL)
|
|
69
|
-
i = j;
|
|
70
|
-
else
|
|
71
|
-
i = Math.min(j + 1, text.length - 1);
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
continue; // bare ESC (or an unrecognized ESC-prefixed byte): drop just the ESC byte
|
|
75
|
-
}
|
|
76
|
-
if (isOtherControlCode(code))
|
|
85
|
+
i = escapeSequenceEnd(text, i); // land on the last consumed byte; the loop's i++ steps past it
|
|
77
86
|
continue;
|
|
78
|
-
|
|
87
|
+
}
|
|
88
|
+
if (!isOtherControlCode(code))
|
|
89
|
+
out += text[i];
|
|
79
90
|
}
|
|
80
91
|
return out;
|
|
81
92
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
/** Exact diagnostic vocabulary only: unknown spellings and glued suffixes remain opaque. */
|
|
94
|
+
const DIAGNOSTIC_TOKENS = new Set([
|
|
95
|
+
'additionalProperties', 'additionalItems', 'unevaluatedProperties', 'unevaluatedItems',
|
|
96
|
+
'dependentRequired', 'dependentSchemas', 'exclusiveMinimum', 'exclusiveMaximum',
|
|
97
|
+
'minContains', 'maxContains', 'patternProperties', 'propertyNames', 'prefixItems',
|
|
98
|
+
'contentEncoding', 'contentMediaType', 'contentSchema',
|
|
99
|
+
]);
|
|
100
|
+
/** Mask credentials before preserving complete trusted IDs and UPPER_SNAKE diagnostics; redact opaque spans elsewhere. */
|
|
101
|
+
function redactSecretLike({ text, diagnosticToolIds }) {
|
|
102
|
+
return redactSecrets({ input: text }, { redactPii: false })
|
|
103
|
+
.replace(LABELED_SECRET_RE, match => match.replace(/([:=]\s*)\S+$/, '$1[redacted]'))
|
|
104
|
+
.replace(/\[REDACTED:[^\]\r\n]+\]/g, '[redacted]')
|
|
105
|
+
// Trust whole dotted IDs so their components cannot become a general redaction bypass.
|
|
106
|
+
.replace(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g, token => diagnosticToolIds.has(token) ? token
|
|
107
|
+
: token.replace(OPAQUE_TOKEN_RE, match => NON_SECRET_TOKEN_RE.test(match) || DIAGNOSTIC_TOKENS.has(match) ? match : '[redacted]'));
|
|
90
108
|
}
|
|
91
109
|
/**
|
|
92
110
|
* Strip terminal control sequences, redact anything that looks like a secret, and cap the
|
|
93
111
|
* length of `text` — the one function every boundary that surfaces daemon/network-derived text
|
|
94
112
|
* to stderr/stdout should route through first.
|
|
113
|
+
* Trusted diagnosticToolIds exempt complete IDs (including dots) only, after credential masking.
|
|
114
|
+
* @complexity O(n + t) local time/space for n text bytes and t trusted ID bytes.
|
|
95
115
|
*/
|
|
96
116
|
export function sanitizeUntrustedText({ text }, options = {}) {
|
|
97
117
|
const requestedLength = options.maxLength ?? DEFAULT_MAX_EXCERPT_LENGTH;
|
|
98
118
|
const maxLength = Number.isFinite(requestedLength) ? Math.max(0, Math.floor(requestedLength)) : DEFAULT_MAX_EXCERPT_LENGTH;
|
|
99
|
-
const sanitized = redactSecretLike({ text: stripControlSequences({ text }) });
|
|
119
|
+
const sanitized = redactSecretLike({ text: stripControlSequences({ text }), diagnosticToolIds: new Set(options.diagnosticToolIds) });
|
|
100
120
|
if (sanitized.length <= maxLength)
|
|
101
121
|
return sanitized;
|
|
102
122
|
// The marker is part of the output budget, including when the budget cannot fit the full marker.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Descriptor-only projections. Execution and current authorization remain with ToolExecutor. */
|
|
2
|
+
import type { ToolDescriptor, ToolRegistry } from './tool-registry.js';
|
|
3
|
+
export interface ToolNameMap {
|
|
4
|
+
wireNameFor(required: {
|
|
5
|
+
toolId: string;
|
|
6
|
+
}, optional?: Record<string, never>): string | undefined;
|
|
7
|
+
toolIdFor(required: {
|
|
8
|
+
wireName: string;
|
|
9
|
+
}, optional?: Record<string, never>): string | undefined;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Build a deterministic, collision-checked mapping to conservative 64-character wire names.
|
|
13
|
+
* @param required - Registry IDs; duplicates share one mapping.
|
|
14
|
+
* @param optional - Names already occupied by transport-owned tools.
|
|
15
|
+
* @returns Read-only forward/reverse lookups, with no descriptors or execution authority.
|
|
16
|
+
* @complexity O(n log n + l + c) time, O(n + l) space for n IDs, l characters and c collision probes.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createToolNameMap({ toolIds }: {
|
|
19
|
+
toolIds: readonly string[];
|
|
20
|
+
}, { reservedNames }?: {
|
|
21
|
+
reservedNames?: readonly string[];
|
|
22
|
+
}): ToolNameMap;
|
|
23
|
+
export interface ExposedToolDescriptor {
|
|
24
|
+
readonly wireName: string;
|
|
25
|
+
readonly descriptor: ToolDescriptor;
|
|
26
|
+
}
|
|
27
|
+
export interface ToolExposureSnapshot {
|
|
28
|
+
readonly revision: number;
|
|
29
|
+
readonly tools: readonly ExposedToolDescriptor[];
|
|
30
|
+
/** UTF-8 bytes of JSON-serialized input schemas, excluding absent schemas. */
|
|
31
|
+
readonly schemaBytes: number;
|
|
32
|
+
}
|
|
33
|
+
export interface ToolExposureSkipped {
|
|
34
|
+
readonly toolId: string;
|
|
35
|
+
readonly reason: 'not-found' | 'tool-limit' | 'schema-byte-limit';
|
|
36
|
+
}
|
|
37
|
+
export interface ToolExposurePromotion {
|
|
38
|
+
readonly snapshot: ToolExposureSnapshot;
|
|
39
|
+
readonly added: readonly string[];
|
|
40
|
+
readonly skipped: readonly ToolExposureSkipped[];
|
|
41
|
+
}
|
|
42
|
+
export interface ToolExposure {
|
|
43
|
+
/** Synchronous and idempotent; successful promotions are never evicted within this scope. */
|
|
44
|
+
promote(required: {
|
|
45
|
+
toolIds: readonly string[];
|
|
46
|
+
}, optional?: Record<string, never>): ToolExposurePromotion;
|
|
47
|
+
getSnapshot(required: Record<string, never>, optional?: Record<string, never>): ToolExposureSnapshot;
|
|
48
|
+
/** Resolves published names only. Callers must still invoke the authoritative executor. */
|
|
49
|
+
resolveToolId(required: {
|
|
50
|
+
wireName: string;
|
|
51
|
+
}, optional?: Record<string, never>): string | undefined;
|
|
52
|
+
}
|
|
53
|
+
export declare class ToolExposureError extends Error {
|
|
54
|
+
readonly code: 'invalid-limit' | 'invalid-descriptor';
|
|
55
|
+
constructor({ code }: {
|
|
56
|
+
code: 'invalid-limit' | 'invalid-descriptor';
|
|
57
|
+
}, options?: {
|
|
58
|
+
cause?: unknown;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create one run's bounded, append-only descriptor exposure over the existing registry.
|
|
63
|
+
* @param required - Descriptor source; a filtered registry view may enforce host visibility.
|
|
64
|
+
* @param optional - Count/schema-byte budgets and transport-owned reserved wire names.
|
|
65
|
+
* @returns Immutable snapshots and promotion deltas; no handlers, policies or execution path.
|
|
66
|
+
* @throws ToolExposureError for invalid limits or non-cloneable/non-serializable descriptors.
|
|
67
|
+
* @complexity Promotion O(r log r + s + b + c) time and O(r + s + b) space for r registry IDs,
|
|
68
|
+
* s exposed entries, b promoted descriptor bytes and c name collision probes. No I/O.
|
|
69
|
+
* @remarks Scope mutation is synchronous, so concurrent promise continuations cannot lose updates.
|
|
70
|
+
* Definitions stay pinned until this object is released; execution must recheck current registry policy.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createToolExposure({ registry }: {
|
|
73
|
+
registry: Pick<ToolRegistry, 'list'>;
|
|
74
|
+
}, { maxTools, maxSchemaBytes, reservedNames }?: {
|
|
75
|
+
maxTools?: number;
|
|
76
|
+
maxSchemaBytes?: number;
|
|
77
|
+
reservedNames?: readonly string[];
|
|
78
|
+
}): ToolExposure;
|
|
79
|
+
//# sourceMappingURL=tool-exposure.d.ts.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
2
|
+
const MAX_NAME_LENGTH = 64;
|
|
3
|
+
/** A stable, non-security hash keeps transformed names independent of discovery order.
|
|
4
|
+
* Collisions are still checked against the entire namespace, never trusted as unique. */
|
|
5
|
+
function nameHash(id) {
|
|
6
|
+
let hash = 2166136261;
|
|
7
|
+
for (let i = 0; i < id.length; i++)
|
|
8
|
+
hash = Math.imul(hash ^ id.charCodeAt(i), 16777619);
|
|
9
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
10
|
+
}
|
|
11
|
+
function nameBase(id) {
|
|
12
|
+
if (SAFE_NAME.test(id))
|
|
13
|
+
return id;
|
|
14
|
+
const readable = id.replace(/[^A-Za-z0-9_]/g, '_');
|
|
15
|
+
return `tool_${readable.slice(0, 49)}_${nameHash(id)}`;
|
|
16
|
+
}
|
|
17
|
+
/** O(c) collision probes for c occupied alternatives; suffix space stays inside the wire limit. */
|
|
18
|
+
function assignName(index, id) {
|
|
19
|
+
if (index.forward.has(id))
|
|
20
|
+
return;
|
|
21
|
+
const base = nameBase(id);
|
|
22
|
+
let candidate = base;
|
|
23
|
+
let counter = 1;
|
|
24
|
+
while (index.occupied.has(candidate)) {
|
|
25
|
+
const suffix = `_${counter++}`;
|
|
26
|
+
candidate = `${base.slice(0, MAX_NAME_LENGTH - suffix.length)}${suffix}`;
|
|
27
|
+
}
|
|
28
|
+
index.forward.set(id, candidate);
|
|
29
|
+
index.reverse.set(candidate, id);
|
|
30
|
+
index.occupied.add(candidate);
|
|
31
|
+
}
|
|
32
|
+
/** Reserve native spellings first so a transformed ID cannot steal an existing safe name. */
|
|
33
|
+
function indexNames(index, toolIds) {
|
|
34
|
+
const sorted = [...new Set(toolIds)].sort();
|
|
35
|
+
for (const id of sorted)
|
|
36
|
+
if (SAFE_NAME.test(id))
|
|
37
|
+
assignName(index, id);
|
|
38
|
+
for (const id of sorted)
|
|
39
|
+
if (!SAFE_NAME.test(id))
|
|
40
|
+
assignName(index, id);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a deterministic, collision-checked mapping to conservative 64-character wire names.
|
|
44
|
+
* @param required - Registry IDs; duplicates share one mapping.
|
|
45
|
+
* @param optional - Names already occupied by transport-owned tools.
|
|
46
|
+
* @returns Read-only forward/reverse lookups, with no descriptors or execution authority.
|
|
47
|
+
* @complexity O(n log n + l + c) time, O(n + l) space for n IDs, l characters and c collision probes.
|
|
48
|
+
*/
|
|
49
|
+
export function createToolNameMap({ toolIds }, { reservedNames = [] } = {}) {
|
|
50
|
+
const index = { forward: new Map(), reverse: new Map(), occupied: new Set(reservedNames) };
|
|
51
|
+
indexNames(index, toolIds);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
wireNameFor: ({ toolId }, _optional = {}) => index.forward.get(toolId),
|
|
54
|
+
toolIdFor: ({ wireName }, _optional = {}) => index.reverse.get(wireName),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export class ToolExposureError extends Error {
|
|
58
|
+
code;
|
|
59
|
+
constructor({ code }, options = {}) {
|
|
60
|
+
super(`ToolExposure: ${code}`, options);
|
|
61
|
+
this.name = 'ToolExposureError';
|
|
62
|
+
this.code = code;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function assertLimit(value) {
|
|
66
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
67
|
+
throw new ToolExposureError({ code: 'invalid-limit' });
|
|
68
|
+
}
|
|
69
|
+
/** Freeze detached data only; registry-owned descriptors are never frozen or mutated. */
|
|
70
|
+
function freezeData(value, seen = new Set()) {
|
|
71
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value) || seen.has(value))
|
|
72
|
+
return;
|
|
73
|
+
seen.add(value);
|
|
74
|
+
for (const child of Object.values(value))
|
|
75
|
+
freezeData(child, seen);
|
|
76
|
+
Object.freeze(value);
|
|
77
|
+
}
|
|
78
|
+
/** Clone and measure before publication. Invalid data cannot partially change exposure state. */
|
|
79
|
+
function pinDescriptor(descriptor) {
|
|
80
|
+
try {
|
|
81
|
+
const pinned = structuredClone(descriptor);
|
|
82
|
+
const json = JSON.stringify(pinned.inputSchema);
|
|
83
|
+
const bytes = json === undefined ? 0 : new TextEncoder().encode(json).byteLength;
|
|
84
|
+
freezeData(pinned);
|
|
85
|
+
return { descriptor: pinned, bytes };
|
|
86
|
+
}
|
|
87
|
+
catch (cause) {
|
|
88
|
+
throw new ToolExposureError({ code: 'invalid-descriptor' }, { cause });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Count and byte caps reject new entries without evicting any previously callable definition. */
|
|
92
|
+
function limitReason(snapshot, bytes, limits) {
|
|
93
|
+
if (snapshot.tools.length >= limits.maxTools)
|
|
94
|
+
return 'tool-limit';
|
|
95
|
+
if (snapshot.schemaBytes + bytes > limits.maxSchemaBytes)
|
|
96
|
+
return 'schema-byte-limit';
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Stage all changes locally so malformed descriptors leave the last published revision intact. */
|
|
100
|
+
function promoteDescriptors(snapshot, toolIds, descriptors, names, limits) {
|
|
101
|
+
const available = new Map(descriptors.map(descriptor => [descriptor.id, descriptor]));
|
|
102
|
+
const published = new Set(snapshot.tools.map(tool => tool.descriptor.id));
|
|
103
|
+
const tools = [...snapshot.tools];
|
|
104
|
+
const added = [];
|
|
105
|
+
const skipped = [];
|
|
106
|
+
let schemaBytes = snapshot.schemaBytes;
|
|
107
|
+
for (const toolId of new Set(toolIds)) {
|
|
108
|
+
if (published.has(toolId))
|
|
109
|
+
continue;
|
|
110
|
+
const descriptor = available.get(toolId);
|
|
111
|
+
if (!descriptor) {
|
|
112
|
+
skipped.push({ toolId, reason: 'not-found' });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const pinned = pinDescriptor(descriptor);
|
|
116
|
+
const reason = limitReason({ tools, schemaBytes, revision: snapshot.revision }, pinned.bytes, limits);
|
|
117
|
+
if (reason) {
|
|
118
|
+
skipped.push({ toolId, reason });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
assignName(names, toolId);
|
|
122
|
+
tools.push({ wireName: names.forward.get(toolId), descriptor: pinned.descriptor });
|
|
123
|
+
schemaBytes += pinned.bytes;
|
|
124
|
+
added.push(toolId);
|
|
125
|
+
}
|
|
126
|
+
let next = snapshot;
|
|
127
|
+
if (added.length > 0)
|
|
128
|
+
next = { revision: snapshot.revision + 1, tools, schemaBytes };
|
|
129
|
+
const result = { snapshot: next, added, skipped };
|
|
130
|
+
freezeData(result);
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Create one run's bounded, append-only descriptor exposure over the existing registry.
|
|
135
|
+
* @param required - Descriptor source; a filtered registry view may enforce host visibility.
|
|
136
|
+
* @param optional - Count/schema-byte budgets and transport-owned reserved wire names.
|
|
137
|
+
* @returns Immutable snapshots and promotion deltas; no handlers, policies or execution path.
|
|
138
|
+
* @throws ToolExposureError for invalid limits or non-cloneable/non-serializable descriptors.
|
|
139
|
+
* @complexity Promotion O(r log r + s + b + c) time and O(r + s + b) space for r registry IDs,
|
|
140
|
+
* s exposed entries, b promoted descriptor bytes and c name collision probes. No I/O.
|
|
141
|
+
* @remarks Scope mutation is synchronous, so concurrent promise continuations cannot lose updates.
|
|
142
|
+
* Definitions stay pinned until this object is released; execution must recheck current registry policy.
|
|
143
|
+
*/
|
|
144
|
+
export function createToolExposure({ registry }, { maxTools = 64, maxSchemaBytes = 262_144, reservedNames = [] } = {}) {
|
|
145
|
+
assertLimit(maxTools);
|
|
146
|
+
assertLimit(maxSchemaBytes);
|
|
147
|
+
const names = { forward: new Map(), reverse: new Map(), occupied: new Set(reservedNames) };
|
|
148
|
+
let snapshot = Object.freeze({ revision: 0, tools: Object.freeze([]), schemaBytes: 0 });
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
promote({ toolIds }, _optional = {}) {
|
|
151
|
+
const descriptors = registry.list({});
|
|
152
|
+
indexNames(names, descriptors.map(descriptor => descriptor.id));
|
|
153
|
+
const result = promoteDescriptors(snapshot, toolIds, descriptors, names, { maxTools, maxSchemaBytes });
|
|
154
|
+
snapshot = result.snapshot;
|
|
155
|
+
return result;
|
|
156
|
+
},
|
|
157
|
+
getSnapshot(_required, _optional = {}) { return snapshot; },
|
|
158
|
+
resolveToolId({ wireName }, _optional = {}) {
|
|
159
|
+
return snapshot.tools.find(tool => tool.wireName === wireName)?.descriptor.id;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=tool-exposure.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @file Dependency-free tool input errors shared by registration and normalization. */
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by a `ToolHandler` (or a validator it calls, e.g. the kernel's `registration-kit.ts`
|
|
4
|
+
* `requireString`/`requireInputRecord`/etc.) to mean "the caller's input was malformed or missing a
|
|
5
|
+
* required field" — a fact about the CALL, not the server. `@jini-ai/daemon`'s `ToolExecutor.execute`
|
|
6
|
+
* tags a handler rejection's `ToolExecutionResult.errorKind` as `'validation'` when the thrown error
|
|
7
|
+
* is `instanceof ToolInputError`, and every HTTP-facing mapping of a `'failed'` execution (today:
|
|
8
|
+
* `@jini-ai/http-kit`'s `delegated-tools.ts`) uses that tag to answer with a 4xx instead of folding
|
|
9
|
+
* it into the same redacted 500 an actual internal/infrastructure failure gets.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately a plain marker with no extra fields: the message itself is already caller-actionable
|
|
12
|
+
* (`withSchemaOnRejection` appends the tool's own input schema to it), so nothing beyond "this IS one
|
|
13
|
+
* of these" needs to survive the `instanceof` check.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ToolInputError extends Error {
|
|
16
|
+
constructor({ message }: {
|
|
17
|
+
message: string;
|
|
18
|
+
}, optional?: ErrorOptions);
|
|
19
|
+
}
|
|
20
|
+
/** Keeps caller shape failures distinct from infrastructure/policy failures in the gate. */
|
|
21
|
+
export declare class ToolInputNormalizationError extends ToolInputError {
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=tool-input-errors.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** @file Dependency-free tool input errors shared by registration and normalization. */
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by a `ToolHandler` (or a validator it calls, e.g. the kernel's `registration-kit.ts`
|
|
4
|
+
* `requireString`/`requireInputRecord`/etc.) to mean "the caller's input was malformed or missing a
|
|
5
|
+
* required field" — a fact about the CALL, not the server. `@jini-ai/daemon`'s `ToolExecutor.execute`
|
|
6
|
+
* tags a handler rejection's `ToolExecutionResult.errorKind` as `'validation'` when the thrown error
|
|
7
|
+
* is `instanceof ToolInputError`, and every HTTP-facing mapping of a `'failed'` execution (today:
|
|
8
|
+
* `@jini-ai/http-kit`'s `delegated-tools.ts`) uses that tag to answer with a 4xx instead of folding
|
|
9
|
+
* it into the same redacted 500 an actual internal/infrastructure failure gets.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately a plain marker with no extra fields: the message itself is already caller-actionable
|
|
12
|
+
* (`withSchemaOnRejection` appends the tool's own input schema to it), so nothing beyond "this IS one
|
|
13
|
+
* of these" needs to survive the `instanceof` check.
|
|
14
|
+
*/
|
|
15
|
+
export class ToolInputError extends Error {
|
|
16
|
+
constructor({ message }, optional = {}) {
|
|
17
|
+
super(message, optional);
|
|
18
|
+
this.name = 'ToolInputError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Keeps caller shape failures distinct from infrastructure/policy failures in the gate. */
|
|
22
|
+
export class ToolInputNormalizationError extends ToolInputError {
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=tool-input-errors.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ToolRegistration } from './tool-registry.js';
|
|
2
|
+
export { ToolInputNormalizationError } from './tool-input-errors.js';
|
|
3
|
+
/** A value-free account of canonicalization; domain hooks use the same contract as the gate. */
|
|
4
|
+
export interface NormalizedToolInput {
|
|
5
|
+
readonly input: unknown;
|
|
6
|
+
readonly notes: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export type ToolInputNormalizer = (required: {
|
|
9
|
+
input: Readonly<Record<string, unknown>>;
|
|
10
|
+
}, optional?: Record<string, never>) => NormalizedToolInput | Promise<NormalizedToolInput>;
|
|
11
|
+
/** Canonicalize one batch layer before merging; preserve values and domain aliases for the
|
|
12
|
+
* final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
13
|
+
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
14
|
+
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
15
|
+
*/
|
|
16
|
+
export declare function canonicalizeToolInputKeys({ input, schema }: {
|
|
17
|
+
input: Readonly<Record<string, unknown>>;
|
|
18
|
+
schema: unknown;
|
|
19
|
+
}, _options?: Record<string, never>): {
|
|
20
|
+
input: Record<string, unknown>;
|
|
21
|
+
notes: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
/** Normalize before policy, confirmation and handler validation. Domain hooks own semantic aliases.
|
|
24
|
+
* Never changes the caller's object or includes values in notes. Invalid/ambiguous input remains
|
|
25
|
+
* a ToolInputError; ordinary domain/schema validation still belongs to the registered handler.
|
|
26
|
+
* @complexity Two bounded schema walks plus the optional domain hook's cost.
|
|
27
|
+
*/
|
|
28
|
+
export declare function normalizeToolInput({ input, registration }: {
|
|
29
|
+
input: unknown;
|
|
30
|
+
registration: Pick<ToolRegistration, 'descriptor' | 'normalizeInput' | 'invocationSchema'>;
|
|
31
|
+
}, _options?: Record<string, never>): Promise<NormalizedToolInput>;
|
|
32
|
+
/** Keep result shapes intact and make rewrite notes visible in plain and MCP tool results. */
|
|
33
|
+
export declare function withNormalizationNotes({ output, notes }: {
|
|
34
|
+
output: unknown;
|
|
35
|
+
notes: readonly string[];
|
|
36
|
+
}, _options?: Record<string, never>): unknown;
|
|
37
|
+
//# sourceMappingURL=tool-input-normalization.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { isNonArrayRecord } from './records.js';
|
|
2
|
+
import { ToolInputError, ToolInputNormalizationError } from './tool-input-errors.js';
|
|
3
|
+
export { ToolInputNormalizationError } from './tool-input-errors.js';
|
|
4
|
+
const object = (value) => isNonArrayRecord({ value });
|
|
5
|
+
const keyShape = (key) => key.replace(/[_-]/g, '').toLowerCase();
|
|
6
|
+
/** Length of the shared prefix of two strings. */
|
|
7
|
+
function commonPrefixLength({ left, right }) {
|
|
8
|
+
let index = 0;
|
|
9
|
+
while (index < left.length && left[index] === right[index])
|
|
10
|
+
index++;
|
|
11
|
+
return index;
|
|
12
|
+
}
|
|
13
|
+
/** Same-length strings that differ by one substitution or one adjacent transposition at `index`. */
|
|
14
|
+
function sameLengthNearMiss({ left, right, index }) {
|
|
15
|
+
return left.slice(index + 1) === right.slice(index + 1)
|
|
16
|
+
|| (left[index] === right[index + 1] && left[index + 1] === right[index] && left.slice(index + 2) === right.slice(index + 2));
|
|
17
|
+
}
|
|
18
|
+
/** Whether either key is the generic id shorthand for the other's id suffix; hints only. */
|
|
19
|
+
function isIdShorthand({ left, right }, _options = {}) {
|
|
20
|
+
return (left === 'id' && right.endsWith('id')) || (right === 'id' && left.endsWith('id'));
|
|
21
|
+
}
|
|
22
|
+
/** Detect id shorthand or one insertion, deletion, substitution or transposition, for hints only. */
|
|
23
|
+
function nearMiss({ left, right }, _options = {}) {
|
|
24
|
+
if (left.length > 128 || right.length > 128)
|
|
25
|
+
return false;
|
|
26
|
+
if (isIdShorthand({ left, right }))
|
|
27
|
+
return true;
|
|
28
|
+
if (Math.abs(left.length - right.length) > 1)
|
|
29
|
+
return false;
|
|
30
|
+
const index = commonPrefixLength({ left, right });
|
|
31
|
+
if (left.length === right.length)
|
|
32
|
+
return sameLengthNearMiss({ left, right, index });
|
|
33
|
+
return left.length > right.length ? left.slice(index + 1) === right.slice(index) : left.slice(index) === right.slice(index + 1);
|
|
34
|
+
}
|
|
35
|
+
const NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
36
|
+
/** Number/integer coercion; returns `value` itself when the string is not a representable number. */
|
|
37
|
+
function coerceNumber({ value, types, path, notes }) {
|
|
38
|
+
const numeric = types.includes('number');
|
|
39
|
+
const integer = types.includes('integer');
|
|
40
|
+
if (!(numeric || integer) || !NUMBER_PATTERN.test(value.trim()))
|
|
41
|
+
return value;
|
|
42
|
+
const number = Number(value);
|
|
43
|
+
if (!Number.isFinite(number) || (integer && !numeric && !Number.isSafeInteger(number)))
|
|
44
|
+
return value;
|
|
45
|
+
notes.push(`${path}: string→${numeric ? 'number' : 'integer'}`);
|
|
46
|
+
return number;
|
|
47
|
+
}
|
|
48
|
+
/** Boolean words first, then numbers; any other string is left for the handler's validator. */
|
|
49
|
+
function coerceString({ value, types, path, notes }) {
|
|
50
|
+
if (types.includes('boolean') && /^(true|false)$/i.test(value.trim())) {
|
|
51
|
+
notes.push(`${path}: string→boolean`);
|
|
52
|
+
return value.trim().toLowerCase() === 'true';
|
|
53
|
+
}
|
|
54
|
+
return coerceNumber({ value, types, path, notes });
|
|
55
|
+
}
|
|
56
|
+
function normalizeArray({ value, schema, step }) {
|
|
57
|
+
const { path, depth, walk } = step;
|
|
58
|
+
let list = value;
|
|
59
|
+
// The single-object shorthand applies to items batching, never arbitrary domain arrays.
|
|
60
|
+
if (!walk.keysOnly && object(value) && path.split('.').at(-1) === 'items') {
|
|
61
|
+
walk.notes.push(`${path}: object→one-item array`);
|
|
62
|
+
list = [value];
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(list))
|
|
65
|
+
return list;
|
|
66
|
+
return list.map((item, index) => normalizeValue({ value: item, schema: schema.items, step: { path: `${path}[${index}]`, depth: depth + 1, walk } }));
|
|
67
|
+
}
|
|
68
|
+
/** The declared property a supplied key names, or undefined; refuses a key that names two. */
|
|
69
|
+
function canonicalKey({ key, location, shape }) {
|
|
70
|
+
const candidates = Object.hasOwn(shape.properties, key) ? [key] : shape.names.filter(name => keyShape(name) === keyShape(key));
|
|
71
|
+
if (candidates.length > 1)
|
|
72
|
+
throw new ToolInputError({ message: `Ambiguous input '${location}': matches ${candidates.join(', ')}. Use exactly one canonical property name.` });
|
|
73
|
+
return candidates[0];
|
|
74
|
+
}
|
|
75
|
+
/** Describe the local schema and likely names without choosing a name or exposing input values.
|
|
76
|
+
* Target and permission fields must be declared in the tool schema before callers can use them.
|
|
77
|
+
* @complexity O(p) time and space for p declared properties; name comparisons are length-bounded.
|
|
78
|
+
*/
|
|
79
|
+
function unknownKeyError({ key, location, shape }, _options = {}) {
|
|
80
|
+
const required = new Set(shape.required);
|
|
81
|
+
const names = [...shape.names.filter(name => required.has(name)), ...shape.names.filter(name => !required.has(name))];
|
|
82
|
+
const declared = names.map(name => `${name} (${required.has(name) ? 'required' : 'optional'})`).join(', ') || 'none';
|
|
83
|
+
const hints = names.filter(name => nearMiss({ left: keyShape(key), right: keyShape(name) }));
|
|
84
|
+
const suggestion = hints.length ? ` Did you mean ${hints.map(name => `'${name}'`).join(', ')}?` : '';
|
|
85
|
+
return new ToolInputError({ message: `Unknown input '${location}'. Declared inputs: ${declared}.${suggestion} Resend with the declared name.` });
|
|
86
|
+
}
|
|
87
|
+
/** One key of an object: canonical rename + recursion, closed-schema refusal, or open pass-through. */
|
|
88
|
+
function normalizeEntry({ key, raw, shape, sources, step }) {
|
|
89
|
+
const { path, depth, walk } = step;
|
|
90
|
+
const location = path ? `${path}.${key}` : key;
|
|
91
|
+
const target = canonicalKey({ key, location, shape });
|
|
92
|
+
if (target === undefined) {
|
|
93
|
+
if (walk.validateClosed && shape.schema.additionalProperties === false)
|
|
94
|
+
throw unknownKeyError({ key, location, shape });
|
|
95
|
+
const childSchema = object(shape.schema.additionalProperties) ? shape.schema.additionalProperties : undefined;
|
|
96
|
+
return [key, normalizeValue({ value: raw, schema: childSchema, step: { path: location, depth: depth + 1, walk } })];
|
|
97
|
+
}
|
|
98
|
+
const previous = sources.get(target);
|
|
99
|
+
if (previous !== undefined)
|
|
100
|
+
throw new ToolInputError({ message: `Conflicting inputs '${previous}' and '${key}' both name '${target}'. Send only '${target}'.` });
|
|
101
|
+
sources.set(target, key);
|
|
102
|
+
if (target !== key)
|
|
103
|
+
walk.notes.push(`${location}→${target}`);
|
|
104
|
+
return [target, normalizeValue({ value: raw, schema: shape.properties[target], step: { path: `${path ? `${path}.` : ''}${target}`, depth: depth + 1, walk } })];
|
|
105
|
+
}
|
|
106
|
+
function normalizeObject({ value, schema, step }) {
|
|
107
|
+
const properties = object(schema.properties) ? schema.properties : {};
|
|
108
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((key) => typeof key === 'string') : [];
|
|
109
|
+
const shape = { schema, properties, names: Object.keys(properties), required };
|
|
110
|
+
const sources = new Map();
|
|
111
|
+
return Object.fromEntries(Object.entries(value).map(([key, raw]) => normalizeEntry({ key, raw, shape, sources, step })));
|
|
112
|
+
}
|
|
113
|
+
/** Schema-guided copies only: unconstrained data and unsupported schema dialects stay untouched.
|
|
114
|
+
* Cost is O(k*p) per object with k supplied keys and p schema properties; recursion is bounded
|
|
115
|
+
* to 64 levels so a hostile input/schema cannot exhaust the execution gate's stack.
|
|
116
|
+
*/
|
|
117
|
+
function normalizeValue({ value, schema, step }) {
|
|
118
|
+
if (!object(schema))
|
|
119
|
+
return value;
|
|
120
|
+
if (step.depth > 64)
|
|
121
|
+
throw new ToolInputError({ message: 'Tool input exceeds the 64-level normalization limit. Send a shallower object.' });
|
|
122
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
123
|
+
// A string is never an array or object, so it is either coerced here or returned as-is.
|
|
124
|
+
if (typeof value === 'string') {
|
|
125
|
+
return step.walk.keysOnly || types.includes('string') ? value : coerceString({ value, types, path: step.path, notes: step.walk.notes });
|
|
126
|
+
}
|
|
127
|
+
if (types.includes('array'))
|
|
128
|
+
return normalizeArray({ value, schema, step });
|
|
129
|
+
return object(value) ? normalizeObject({ value, schema, step }) : value;
|
|
130
|
+
}
|
|
131
|
+
/** A walk from the root of one input. */
|
|
132
|
+
function walkRoot({ input, schema, notes, validateClosed, keysOnly }) {
|
|
133
|
+
return normalizeValue({ value: input, schema, step: { path: '', depth: 0, walk: { notes, validateClosed, keysOnly } } });
|
|
134
|
+
}
|
|
135
|
+
/** Canonicalize one batch layer before merging; preserve values and domain aliases for the
|
|
136
|
+
* final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
137
|
+
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
138
|
+
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
139
|
+
*/
|
|
140
|
+
export function canonicalizeToolInputKeys({ input, schema }, _options = {}) {
|
|
141
|
+
const notes = [];
|
|
142
|
+
return { input: walkRoot({ input, schema, notes, validateClosed: false, keysOnly: true }), notes };
|
|
143
|
+
}
|
|
144
|
+
/** Normalize before policy, confirmation and handler validation. Domain hooks own semantic aliases.
|
|
145
|
+
* Never changes the caller's object or includes values in notes. Invalid/ambiguous input remains
|
|
146
|
+
* a ToolInputError; ordinary domain/schema validation still belongs to the registered handler.
|
|
147
|
+
* @complexity Two bounded schema walks plus the optional domain hook's cost.
|
|
148
|
+
*/
|
|
149
|
+
export async function normalizeToolInput({ input, registration }, _options = {}) {
|
|
150
|
+
try {
|
|
151
|
+
return await normalizeWithinGate({ input, registration });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error instanceof ToolInputError)
|
|
155
|
+
throw new ToolInputNormalizationError({ message: error.message });
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const isObjectTool = (schema) => object(schema) && schema.type === 'object';
|
|
160
|
+
/** First pass (open), the optional domain hook, then the final closed-schema pass. */
|
|
161
|
+
async function normalizeWithinGate({ input, registration }) {
|
|
162
|
+
const schema = registration.invocationSchema ?? registration.descriptor.inputSchema;
|
|
163
|
+
if (isObjectTool(schema) && !object(input))
|
|
164
|
+
throw new ToolInputError({ message: `${registration.descriptor.id}: send one input object containing the tool's fields.` });
|
|
165
|
+
const notes = [];
|
|
166
|
+
let normalized = walkRoot({ input, schema, notes, validateClosed: false, keysOnly: false });
|
|
167
|
+
if (registration.normalizeInput && object(normalized)) {
|
|
168
|
+
const result = await registration.normalizeInput({ input: normalized });
|
|
169
|
+
normalized = result.input;
|
|
170
|
+
notes.push(...result.notes);
|
|
171
|
+
}
|
|
172
|
+
if (isObjectTool(schema) && !object(normalized))
|
|
173
|
+
throw new ToolInputError({ message: `${registration.descriptor.id}: normalization must produce one input object.` });
|
|
174
|
+
return { input: walkRoot({ input: normalized, schema, notes, validateClosed: true, keysOnly: false }), notes };
|
|
175
|
+
}
|
|
176
|
+
/** Keep result shapes intact and make rewrite notes visible in plain and MCP tool results. */
|
|
177
|
+
export function withNormalizationNotes({ output, notes }, _options = {}) {
|
|
178
|
+
if (!notes.length)
|
|
179
|
+
return output;
|
|
180
|
+
const text = `normalized: ${notes.join('; ')}`;
|
|
181
|
+
if (typeof output === 'string')
|
|
182
|
+
return `${output}\n${text}`;
|
|
183
|
+
if (object(output))
|
|
184
|
+
return { ...output, normalizationNotes: notes,
|
|
185
|
+
...(Array.isArray(output.content) ? { content: [...output.content, { type: 'text', text }] } : {}) };
|
|
186
|
+
return { result: output, normalizationNotes: notes };
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=tool-input-normalization.js.map
|