@ganju/utils 0.0.13 → 0.0.15
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/channelCustomer.d.ts +99 -0
- package/dist/channelCustomer.d.ts.map +1 -0
- package/dist/channelCustomer.js +533 -0
- package/dist/channelDebounce.d.ts +7 -0
- package/dist/channelDebounce.d.ts.map +1 -1
- package/dist/chunkOverlap.d.ts +2 -0
- package/dist/chunkOverlap.d.ts.map +1 -0
- package/dist/chunkOverlap.js +9 -0
- package/dist/chunking.d.ts.map +1 -1
- package/dist/chunking.js +24 -4
- package/dist/citations.d.ts +28 -0
- package/dist/citations.d.ts.map +1 -0
- package/dist/citations.js +111 -0
- package/dist/constants.d.ts +26 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +108 -10
- package/dist/index.d.ts +263 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +70 -0
- package/dist/jsonSchemaToZodShape.d.ts +5 -1
- package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
- package/dist/jsonSchemaToZodShape.js +21 -9
- package/dist/pagination.d.ts +47 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +160 -0
- package/dist/processQueueBatch.d.ts +6 -1
- package/dist/processQueueBatch.d.ts.map +1 -1
- package/dist/processQueueBatch.js +23 -10
- package/dist/queueFailure.d.ts +14 -0
- package/dist/queueFailure.d.ts.map +1 -0
- package/dist/queueFailure.js +43 -0
- package/dist/resourceRerank.d.ts.map +1 -1
- package/dist/resourceRerank.js +3 -1
- package/dist/schema.d.ts +90 -26
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +87 -6
- package/dist/ssrf.d.ts +1 -0
- package/dist/ssrf.d.ts.map +1 -1
- package/dist/ssrf.js +48 -12
- package/dist/toolConfirmation.d.ts +48 -0
- package/dist/toolConfirmation.d.ts.map +1 -1
- package/dist/toolConfirmation.js +75 -1
- package/dist/toolCount.d.ts +13 -0
- package/dist/toolCount.d.ts.map +1 -0
- package/dist/toolCount.js +45 -0
- package/dist/toolLint.d.ts +1 -1
- package/dist/toolLint.d.ts.map +1 -1
- package/dist/toolLint.js +37 -3
- package/dist/untrustedContent.d.ts +135 -0
- package/dist/untrustedContent.d.ts.map +1 -0
- package/dist/untrustedContent.js +402 -0
- package/package.json +2 -1
- package/src/channelCustomer.ts +604 -0
- package/src/channelDebounce.ts +17 -0
- package/src/chunkOverlap.ts +6 -0
- package/src/chunking.ts +25 -4
- package/src/citations.ts +127 -0
- package/src/constants.ts +112 -10
- package/src/index.ts +152 -2
- package/src/jsonSchemaToZodShape.ts +31 -13
- package/src/pagination.ts +193 -0
- package/src/processQueueBatch.ts +42 -12
- package/src/queueFailure.ts +63 -0
- package/src/resourceRerank.ts +5 -1
- package/src/schema.ts +100 -4
- package/src/ssrf.ts +40 -11
- package/src/toolConfirmation.ts +123 -0
- package/src/toolCount.ts +53 -0
- package/src/toolLint.ts +46 -3
- package/src/untrustedContent.ts +476 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as z from 'zod';
|
|
2
2
|
|
|
3
3
|
export type JsonSchemaProperty = {
|
|
4
|
-
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
|
4
|
+
type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
|
|
5
5
|
description?: string;
|
|
6
6
|
minimum?: number;
|
|
7
7
|
maximum?: number;
|
|
@@ -10,6 +10,10 @@ export type JsonSchemaProperty = {
|
|
|
10
10
|
pattern?: string;
|
|
11
11
|
enum?: string[];
|
|
12
12
|
items?: JsonSchemaProperty;
|
|
13
|
+
minItems?: number;
|
|
14
|
+
maxItems?: number;
|
|
15
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
16
|
+
required?: string[];
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
export type JsonSchema = {
|
|
@@ -35,8 +39,9 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
|
|
|
35
39
|
}
|
|
36
40
|
break;
|
|
37
41
|
}
|
|
38
|
-
case 'number':
|
|
39
|
-
|
|
42
|
+
case 'number':
|
|
43
|
+
case 'integer': {
|
|
44
|
+
let num = prop.type === 'integer' ? z.number().int() : z.number();
|
|
40
45
|
if (prop.minimum !== undefined) num = num.min(prop.minimum);
|
|
41
46
|
if (prop.maximum !== undefined) num = num.max(prop.maximum);
|
|
42
47
|
field = num;
|
|
@@ -45,11 +50,21 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
|
|
|
45
50
|
case 'boolean':
|
|
46
51
|
field = z.boolean();
|
|
47
52
|
break;
|
|
48
|
-
case 'array':
|
|
49
|
-
|
|
53
|
+
case 'array': {
|
|
54
|
+
let arr = z.array(prop.items ? propertyToZod(prop.items) : z.any());
|
|
55
|
+
if (prop.minItems !== undefined) arr = arr.min(prop.minItems);
|
|
56
|
+
if (prop.maxItems !== undefined) arr = arr.max(prop.maxItems);
|
|
57
|
+
field = arr;
|
|
50
58
|
break;
|
|
59
|
+
}
|
|
51
60
|
case 'object':
|
|
52
|
-
|
|
61
|
+
// With declared properties, keep them: this shape is also what
|
|
62
|
+
// `tools/list` advertises, and a bare record there tells the model an
|
|
63
|
+
// item is any object at all, so it invents the field names. Loose,
|
|
64
|
+
// because a schema without `additionalProperties: false` allows extras.
|
|
65
|
+
field = prop.properties
|
|
66
|
+
? z.looseObject(shapeOf(prop.properties, prop.required))
|
|
67
|
+
: z.record(z.string(), z.any());
|
|
53
68
|
break;
|
|
54
69
|
default:
|
|
55
70
|
field = z.any();
|
|
@@ -62,12 +77,10 @@ const propertyToZod = (prop: JsonSchemaProperty): z.ZodTypeAny => {
|
|
|
62
77
|
return field;
|
|
63
78
|
};
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const required = schema.required ?? [];
|
|
70
|
-
|
|
80
|
+
function shapeOf(
|
|
81
|
+
properties: Record<string, JsonSchemaProperty> = {},
|
|
82
|
+
required: string[] = []
|
|
83
|
+
): Record<string, z.ZodTypeAny> {
|
|
71
84
|
const shape: Record<string, z.ZodTypeAny> = {};
|
|
72
85
|
|
|
73
86
|
for (const [key, prop] of Object.entries(properties)) {
|
|
@@ -76,7 +89,12 @@ export const jsonSchemaToZodShape = (
|
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
return shape;
|
|
79
|
-
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const jsonSchemaToZodShape = (
|
|
95
|
+
schema: JsonSchema
|
|
96
|
+
): Record<string, z.ZodTypeAny> =>
|
|
97
|
+
shapeOf(schema.properties, schema.required);
|
|
80
98
|
|
|
81
99
|
export interface SchemaViolation {
|
|
82
100
|
// Dotted path to the offending field, empty for the value as a whole.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Large tool results, in one shape: a list a tool owns is paged by a cursor the
|
|
2
|
+
// model passes back, and text that has to be cut says so, how much was left
|
|
3
|
+
// out, and where to continue when the result itself carried a cursor.
|
|
4
|
+
//
|
|
5
|
+
// Imports nothing, so node --test runs it directly.
|
|
6
|
+
|
|
7
|
+
// Keys a paged API commonly answers with. Read at the top level only: deeper
|
|
8
|
+
// than that, a key with one of these names is somebody's data.
|
|
9
|
+
const NEXT_CURSOR_KEYS = [
|
|
10
|
+
'nextCursor',
|
|
11
|
+
'next_cursor',
|
|
12
|
+
'nextPageToken',
|
|
13
|
+
'next_page_token',
|
|
14
|
+
'nextPage',
|
|
15
|
+
'next_page',
|
|
16
|
+
'next'
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The cursor for the next page a JSON result names at its top level, if any.
|
|
21
|
+
* Cut text can lose it, so it is read from the parsed value before cutting.
|
|
22
|
+
*/
|
|
23
|
+
export const findNextCursor = (value: unknown): string | undefined => {
|
|
24
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
for (const key of NEXT_CURSOR_KEYS) {
|
|
28
|
+
const found = (value as Record<string, unknown>)[key];
|
|
29
|
+
if (typeof found === 'string' && found) return `${key}: ${found}`;
|
|
30
|
+
if (typeof found === 'number') return `${key}: ${found}`;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const kb = (bytes: number) => `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `text` within `maxBytes` of UTF-8, or its start with a note that says it was
|
|
39
|
+
* cut. A character split by the cut is dropped rather than shown broken.
|
|
40
|
+
* `next` names the result's own cursor, for "call again with it".
|
|
41
|
+
*/
|
|
42
|
+
export const capText = (
|
|
43
|
+
text: string,
|
|
44
|
+
maxBytes: number,
|
|
45
|
+
next?: string
|
|
46
|
+
): string => {
|
|
47
|
+
const bytes = new TextEncoder().encode(text);
|
|
48
|
+
if (bytes.byteLength <= maxBytes) return text;
|
|
49
|
+
|
|
50
|
+
const kept = new TextDecoder()
|
|
51
|
+
.decode(bytes.slice(0, maxBytes))
|
|
52
|
+
.replace(/�+$/, '');
|
|
53
|
+
const continueWith = next
|
|
54
|
+
? ` The result names its next page (${next}): call again with it to continue.`
|
|
55
|
+
: ' To see the rest, call again asking for less — narrower filters, a smaller page or fewer fields.';
|
|
56
|
+
return `${kept}\n\n[Truncated: showing the first ${kb(maxBytes)} of ${kb(
|
|
57
|
+
bytes.byteLength
|
|
58
|
+
)}.${continueWith}]`;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Opaque to the model, so it is passed back as given rather than edited.
|
|
62
|
+
const encodeCursor = (key: string): string =>
|
|
63
|
+
btoa(String.fromCharCode(...new TextEncoder().encode(key)))
|
|
64
|
+
.replace(/\+/g, '-')
|
|
65
|
+
.replace(/\//g, '_')
|
|
66
|
+
.replace(/=+$/, '');
|
|
67
|
+
|
|
68
|
+
const decodeCursor = (cursor: string): string | null => {
|
|
69
|
+
try {
|
|
70
|
+
const base64 = cursor.replace(/-/g, '+').replace(/_/g, '/');
|
|
71
|
+
const binary = atob(base64);
|
|
72
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(
|
|
73
|
+
Uint8Array.from(binary, char => char.charCodeAt(0))
|
|
74
|
+
);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Between a cursor's key and the count of items with that key already served.
|
|
81
|
+
// A key is a uri or a name, never a NUL.
|
|
82
|
+
const CURSOR_SEEN_SEPARATOR = '\u0000';
|
|
83
|
+
|
|
84
|
+
// A cursor with no count (one issued before the count was added) resumes
|
|
85
|
+
// after every item with its key.
|
|
86
|
+
const splitCursorKey = (decoded: string): { key: string; seen: number } => {
|
|
87
|
+
const at = decoded.lastIndexOf(CURSOR_SEEN_SEPARATOR);
|
|
88
|
+
if (at === -1) return { key: decoded, seen: Infinity };
|
|
89
|
+
const count = decoded.slice(at + 1);
|
|
90
|
+
if (!/^\d+$/.test(count)) throw new InvalidCursorError();
|
|
91
|
+
return { key: decoded.slice(0, at), seen: Number(count) };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export class InvalidCursorError extends Error {
|
|
95
|
+
constructor() {
|
|
96
|
+
super(
|
|
97
|
+
'That cursor is not valid. Call again without a cursor to start from the first page.'
|
|
98
|
+
);
|
|
99
|
+
this.name = 'InvalidCursorError';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What `list-resources` answers, wherever it runs (the MCP worker, and the
|
|
105
|
+
* channel runner, which lists proxied resources too): one page by uri, how many
|
|
106
|
+
* there are in all, and the cursor for the next page when there is one.
|
|
107
|
+
*/
|
|
108
|
+
export const listResourcesPage = (
|
|
109
|
+
resources: Array<{
|
|
110
|
+
uri: string;
|
|
111
|
+
title?: string | null;
|
|
112
|
+
description?: string | null;
|
|
113
|
+
mimeType?: string | null;
|
|
114
|
+
}>,
|
|
115
|
+
args: { cursor?: unknown; limit?: unknown },
|
|
116
|
+
limits: { defaultLimit: number; maxLimit: number }
|
|
117
|
+
): string => {
|
|
118
|
+
const { page, total, nextCursor } = paginate(resources, r => r.uri, {
|
|
119
|
+
cursor: typeof args.cursor === 'string' ? args.cursor : undefined,
|
|
120
|
+
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
|
121
|
+
...limits
|
|
122
|
+
});
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
resources: page.map(r => ({
|
|
125
|
+
uri: r.uri,
|
|
126
|
+
title: r.title || undefined,
|
|
127
|
+
description: r.description || undefined,
|
|
128
|
+
mimeType: r.mimeType || undefined
|
|
129
|
+
})),
|
|
130
|
+
total,
|
|
131
|
+
...(nextCursor ? { nextCursor } : {})
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One page of `items`, ordered by `keyOf`, starting after the item the cursor
|
|
137
|
+
* names. Keyed rather than counted, so an item added or removed between calls
|
|
138
|
+
* doesn't shift the next page onto ones already seen.
|
|
139
|
+
*/
|
|
140
|
+
export const paginate = <T>(
|
|
141
|
+
items: T[],
|
|
142
|
+
keyOf: (item: T) => string,
|
|
143
|
+
options: {
|
|
144
|
+
cursor?: string;
|
|
145
|
+
limit?: number;
|
|
146
|
+
defaultLimit: number;
|
|
147
|
+
maxLimit: number;
|
|
148
|
+
}
|
|
149
|
+
): { page: T[]; nextCursor?: string; total: number } => {
|
|
150
|
+
const limit = Math.min(
|
|
151
|
+
Math.max(Math.floor(Number(options.limit) || options.defaultLimit), 1),
|
|
152
|
+
options.maxLimit
|
|
153
|
+
);
|
|
154
|
+
const sorted = [...items].sort((a, b) => {
|
|
155
|
+
const left = keyOf(a);
|
|
156
|
+
const right = keyOf(b);
|
|
157
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
let start = 0;
|
|
161
|
+
if (options.cursor) {
|
|
162
|
+
const decoded = decodeCursor(options.cursor);
|
|
163
|
+
if (decoded === null) throw new InvalidCursorError();
|
|
164
|
+
const { key: after, seen } = splitCursorKey(decoded);
|
|
165
|
+
start = sorted.findIndex(item => keyOf(item) >= after);
|
|
166
|
+
if (start === -1) start = sorted.length;
|
|
167
|
+
// Items that share the cursor's key: skip the ones already served, in the
|
|
168
|
+
// order the sort (which is stable) keeps them.
|
|
169
|
+
for (
|
|
170
|
+
let skipped = 0;
|
|
171
|
+
start < sorted.length && keyOf(sorted[start]) === after && skipped < seen;
|
|
172
|
+
skipped++
|
|
173
|
+
) {
|
|
174
|
+
start++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const page = sorted.slice(start, start + limit);
|
|
179
|
+
const more = start + limit < sorted.length;
|
|
180
|
+
if (!more || page.length === 0) return { page, total: sorted.length };
|
|
181
|
+
|
|
182
|
+
// The last key served, and how many items with that key have been served
|
|
183
|
+
// so far, so a run of equal keys split across pages resumes inside the run.
|
|
184
|
+
const end = start + page.length;
|
|
185
|
+
const lastKey = keyOf(page[page.length - 1]);
|
|
186
|
+
let seen = 0;
|
|
187
|
+
for (let i = end - 1; i >= 0 && keyOf(sorted[i]) === lastKey; i--) seen++;
|
|
188
|
+
return {
|
|
189
|
+
page,
|
|
190
|
+
total: sorted.length,
|
|
191
|
+
nextCursor: encodeCursor(`${lastKey}${CURSOR_SEEN_SEPARATOR}${seen}`)
|
|
192
|
+
};
|
|
193
|
+
};
|
package/src/processQueueBatch.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { isRateLimitError, isTransientInfraError } from './retry';
|
|
2
2
|
import { constants } from './constants';
|
|
3
|
+
import { isPermanentJobError, planQueueFailure } from './queueFailure';
|
|
4
|
+
import type { QueueFailureKind } from './queueFailure';
|
|
3
5
|
|
|
4
6
|
export interface QueueMessageLike<T> {
|
|
5
7
|
body: T;
|
|
8
|
+
// Deliveries so far, including this one (Cloudflare Queues' `attempts`).
|
|
9
|
+
attempts?: number;
|
|
6
10
|
ack: () => void;
|
|
7
11
|
retry: (opts?: { delaySeconds?: number }) => void;
|
|
8
12
|
}
|
|
@@ -12,32 +16,58 @@ export interface QueueBatchLike<T> {
|
|
|
12
16
|
messages: readonly QueueMessageLike<T>[];
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
export interface QueueFailureContext {
|
|
20
|
+
// No delivery follows this one — the moment to mark the work failed. Before
|
|
21
|
+
// it, a retry may still succeed, and a status flipped to failed in between
|
|
22
|
+
// would show a resource as broken while it is being fixed.
|
|
23
|
+
final: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export interface ProcessQueueBatchHandlers<T> {
|
|
16
27
|
process: (body: T) => Promise<void>;
|
|
17
|
-
onError: (
|
|
28
|
+
onError: (
|
|
29
|
+
error: unknown,
|
|
30
|
+
body: T,
|
|
31
|
+
queueName: string,
|
|
32
|
+
context: QueueFailureContext
|
|
33
|
+
) => Promise<void>;
|
|
34
|
+
// The consumer's configured `max_retries`.
|
|
35
|
+
maxRetries?: number;
|
|
18
36
|
}
|
|
19
37
|
|
|
38
|
+
const failureKind = (error: unknown): QueueFailureKind => {
|
|
39
|
+
if (isPermanentJobError(error)) return 'permanent';
|
|
40
|
+
if (isTransientInfraError(error)) return 'transient';
|
|
41
|
+
if (isRateLimitError(error)) return 'rateLimit';
|
|
42
|
+
return 'other';
|
|
43
|
+
};
|
|
44
|
+
|
|
20
45
|
export const processQueueBatch = async <T>(
|
|
21
46
|
batch: QueueBatchLike<T>,
|
|
22
47
|
handlers: ProcessQueueBatchHandlers<T>
|
|
23
48
|
): Promise<void> => {
|
|
49
|
+
const maxRetries = handlers.maxRetries ?? constants.QUEUE_MAX_RETRIES;
|
|
24
50
|
for (const message of batch.messages) {
|
|
25
51
|
try {
|
|
26
52
|
await handlers.process(message.body);
|
|
27
53
|
message.ack();
|
|
28
54
|
} catch (error) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (isRateLimitError(error)) {
|
|
38
|
-
message.retry({
|
|
39
|
-
delaySeconds: constants.RATE_LIMIT_BACKOFF_SECONDS
|
|
55
|
+
const plan = planQueueFailure(
|
|
56
|
+
failureKind(error),
|
|
57
|
+
message.attempts,
|
|
58
|
+
maxRetries
|
|
59
|
+
);
|
|
60
|
+
if (plan.report) {
|
|
61
|
+
await handlers.onError(error, message.body, batch.queue, {
|
|
62
|
+
final: plan.final
|
|
40
63
|
});
|
|
64
|
+
}
|
|
65
|
+
if (plan.action === 'ack') {
|
|
66
|
+
message.ack();
|
|
67
|
+
} else if (plan.backoff === 'transient') {
|
|
68
|
+
message.retry({ delaySeconds: constants.TRANSIENT_BACKOFF_SECONDS });
|
|
69
|
+
} else if (plan.backoff === 'rateLimit') {
|
|
70
|
+
message.retry({ delaySeconds: constants.RATE_LIMIT_BACKOFF_SECONDS });
|
|
41
71
|
} else {
|
|
42
72
|
message.retry();
|
|
43
73
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// How a failed queue job is handled: retried, given up on, or reported. Kept
|
|
2
|
+
// apart from processQueueBatch, which applies it, so the decision is testable
|
|
3
|
+
// on its own.
|
|
4
|
+
|
|
5
|
+
// A failure that no retry can fix — a credential that needs the user to
|
|
6
|
+
// reconnect, a resource that no longer exists. The job is reported once and
|
|
7
|
+
// acknowledged rather than retried into the dead-letter queue. Recognised by
|
|
8
|
+
// its `permanent` flag rather than its class, so an error built by another
|
|
9
|
+
// copy of this module still counts.
|
|
10
|
+
export class PermanentJobError extends Error {
|
|
11
|
+
readonly permanent = true;
|
|
12
|
+
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'PermanentJobError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const isPermanentJobError = (error: unknown): boolean =>
|
|
20
|
+
!!error &&
|
|
21
|
+
typeof error === 'object' &&
|
|
22
|
+
(error as { permanent?: unknown }).permanent === true;
|
|
23
|
+
|
|
24
|
+
export type QueueFailureKind =
|
|
25
|
+
| 'permanent'
|
|
26
|
+
| 'transient'
|
|
27
|
+
| 'rateLimit'
|
|
28
|
+
| 'other';
|
|
29
|
+
|
|
30
|
+
export interface QueueFailurePlan {
|
|
31
|
+
// Hand the failure to the job's onError, which reports it.
|
|
32
|
+
report: boolean;
|
|
33
|
+
// No delivery follows this one: the job's work may be marked failed.
|
|
34
|
+
final: boolean;
|
|
35
|
+
// What to tell the queue about this message.
|
|
36
|
+
action: 'ack' | 'retry';
|
|
37
|
+
// Which backoff a retry waits out; null for the queue's default.
|
|
38
|
+
backoff: 'transient' | 'rateLimit' | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// `attempts` is the queue's own count, 1 on the first delivery; a consumer
|
|
42
|
+
// with `maxRetries` retries gets maxRetries + 1 deliveries in all.
|
|
43
|
+
export const planQueueFailure = (
|
|
44
|
+
kind: QueueFailureKind,
|
|
45
|
+
attempts: number | undefined,
|
|
46
|
+
maxRetries: number
|
|
47
|
+
): QueueFailurePlan => {
|
|
48
|
+
if (kind === 'permanent') {
|
|
49
|
+
return { report: true, final: true, action: 'ack', backoff: null };
|
|
50
|
+
}
|
|
51
|
+
const final = (attempts ?? 1) > maxRetries;
|
|
52
|
+
if (kind === 'transient') {
|
|
53
|
+
// A platform hiccup is retried quietly; only the last one is worth a
|
|
54
|
+
// report, since nothing will run the job after it.
|
|
55
|
+
return { report: final, final, action: 'retry', backoff: 'transient' };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
report: true,
|
|
59
|
+
final,
|
|
60
|
+
action: 'retry',
|
|
61
|
+
backoff: kind === 'rateLimit' ? 'rateLimit' : null
|
|
62
|
+
};
|
|
63
|
+
};
|
package/src/resourceRerank.ts
CHANGED
|
@@ -37,7 +37,11 @@ export const createResourceReranker = (
|
|
|
37
37
|
top_k: documents.length
|
|
38
38
|
})) as RerankResponse;
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// Filled, not just sized: `some` skips the holes of a bare new Array(n), so
|
|
41
|
+
// a document the response left out would pass the check below unscored.
|
|
42
|
+
const scores: Array<number | undefined> = new Array(documents.length).fill(
|
|
43
|
+
undefined
|
|
44
|
+
);
|
|
41
45
|
for (const item of result?.response ?? []) {
|
|
42
46
|
if (
|
|
43
47
|
typeof item.id === 'number' &&
|
package/src/schema.ts
CHANGED
|
@@ -3,9 +3,14 @@ import { z } from 'zod';
|
|
|
3
3
|
import { constants } from './constants';
|
|
4
4
|
import { isAllowedLlmBaseUrl, requiresLlmBaseUrl } from './llmBaseUrl';
|
|
5
5
|
import { isReservedSlug, isValidSlugFormat } from './slug';
|
|
6
|
+
import { isBlockedUrl } from './ssrf';
|
|
6
7
|
import { isReservedToolName } from './reservedToolName';
|
|
7
8
|
import { slugifyTitle } from './slugifyTitle';
|
|
8
9
|
import { TOOL_EFFECTS } from './toolConfirmation';
|
|
10
|
+
import {
|
|
11
|
+
PARTICIPANT_DAILY_LIMIT_MAX,
|
|
12
|
+
PARTICIPANT_DAILY_LIMIT_OFF
|
|
13
|
+
} from './channelCustomer';
|
|
9
14
|
|
|
10
15
|
// A prompt title becomes a slash command; it must not collide with a command
|
|
11
16
|
// the channel runner handles itself (e.g. `/link`).
|
|
@@ -181,6 +186,15 @@ const INVITATION_RESPOND = z.object({
|
|
|
181
186
|
action: z.enum(constants.INVITATION_RESPONSES)
|
|
182
187
|
});
|
|
183
188
|
|
|
189
|
+
const TOOL_APPROVAL_GET = z.object({
|
|
190
|
+
approvalId: z.uuid()
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const TOOL_APPROVAL_DECIDE = z.object({
|
|
194
|
+
approvalId: z.uuid(),
|
|
195
|
+
decision: z.enum(constants.TOOL_APPROVAL_DECISIONS)
|
|
196
|
+
});
|
|
197
|
+
|
|
184
198
|
const INVITATION_GET_BY_TOKEN = z.object({
|
|
185
199
|
token: z.string().min(8).max(128)
|
|
186
200
|
});
|
|
@@ -279,6 +293,10 @@ const ARTIFACT_CREATE_WEBSITE_VIEW = z.object({
|
|
|
279
293
|
.refine(
|
|
280
294
|
v => /^https?:\/\//i.test(v),
|
|
281
295
|
'Only http and https URLs are supported'
|
|
296
|
+
)
|
|
297
|
+
.refine(
|
|
298
|
+
v => !isBlockedUrl(v),
|
|
299
|
+
"This address points to a private network and can't be crawled"
|
|
282
300
|
),
|
|
283
301
|
description: z.string().optional(),
|
|
284
302
|
maxPages: z.number().int().min(1).max(constants.CRAWL_MAX_PAGES_LIMIT),
|
|
@@ -292,6 +310,10 @@ const ARTIFACT_CREATE_WEBSITE = z.object({
|
|
|
292
310
|
.refine(
|
|
293
311
|
v => /^https?:\/\//i.test(v),
|
|
294
312
|
'Only http and https URLs are supported'
|
|
313
|
+
)
|
|
314
|
+
.refine(
|
|
315
|
+
v => !isBlockedUrl(v),
|
|
316
|
+
"This address points to a private network and can't be crawled"
|
|
295
317
|
),
|
|
296
318
|
description: z.string().optional(),
|
|
297
319
|
crawlConfig: z.object({
|
|
@@ -722,11 +744,11 @@ const ORGANIZATION_UPDATE_LLM_VIEW = refineUpdateBaseUrl(
|
|
|
722
744
|
})
|
|
723
745
|
);
|
|
724
746
|
|
|
725
|
-
//
|
|
726
|
-
//
|
|
747
|
+
// The channel settings an owner can write. Strict: a typo or a setting that
|
|
748
|
+
// no longer exists is refused rather than stored, where nothing would read it.
|
|
727
749
|
// `debounceMs` is how long a burst from one participant is buffered before the
|
|
728
750
|
// agent answers it as a single turn; 0 disables buffering.
|
|
729
|
-
const CHANNEL_CONFIG = z.
|
|
751
|
+
const CHANNEL_CONFIG = z.strictObject({
|
|
730
752
|
debounceMs: z
|
|
731
753
|
.number()
|
|
732
754
|
.int()
|
|
@@ -737,7 +759,16 @@ const CHANNEL_CONFIG = z.looseObject({
|
|
|
737
759
|
value <= constants.CHANNEL_DEBOUNCE_MAX_MS),
|
|
738
760
|
`debounceMs must be 0 (disabled) or between ${constants.CHANNEL_DEBOUNCE_MIN_MS} and ${constants.CHANNEL_DEBOUNCE_MAX_MS} ms`
|
|
739
761
|
)
|
|
740
|
-
.optional()
|
|
762
|
+
.optional(),
|
|
763
|
+
// Replies one customer gets in 24 hours; 0 turns the limit off.
|
|
764
|
+
participantDailyLimit: z
|
|
765
|
+
.number()
|
|
766
|
+
.int()
|
|
767
|
+
.min(PARTICIPANT_DAILY_LIMIT_OFF)
|
|
768
|
+
.max(PARTICIPANT_DAILY_LIMIT_MAX)
|
|
769
|
+
.optional(),
|
|
770
|
+
// Remember each customer's name, address, allergies and dates.
|
|
771
|
+
participantMemory: z.boolean().optional()
|
|
741
772
|
});
|
|
742
773
|
|
|
743
774
|
const CHANNEL_CREATE = z.object({
|
|
@@ -754,6 +785,9 @@ const CHANNEL_UPDATE = z.object({
|
|
|
754
785
|
channelId: z.uuid(),
|
|
755
786
|
status: z.enum(constants.CHANNEL_STATUS).optional(),
|
|
756
787
|
config: CHANNEL_CONFIG.optional(),
|
|
788
|
+
// Keys to change on the stored config, merged by the database, so two
|
|
789
|
+
// settings saved at once don't overwrite each other.
|
|
790
|
+
configPatch: CHANNEL_CONFIG.optional(),
|
|
757
791
|
credentials: z.record(z.string(), z.string()).optional(),
|
|
758
792
|
llmId: z.uuid().nullable().optional(),
|
|
759
793
|
projectId: z.uuid(),
|
|
@@ -975,6 +1009,15 @@ const MCP_PROXY_CONFIG = z.object({
|
|
|
975
1009
|
allowedResources: z.array(z.string().min(1).max(2048)).max(500).optional(),
|
|
976
1010
|
allowedPrompts: z.array(z.string().min(1).max(256)).max(500).optional(),
|
|
977
1011
|
auth: MCP_PROXY_AUTH.default({ kind: constants.MCP_PROXY_AUTH_KIND_NONE }),
|
|
1012
|
+
// Whether the remote's own readOnlyHint / destructiveHint decide which of its
|
|
1013
|
+
// tools are confirmed. Off (or absent), every proxied tool counts as
|
|
1014
|
+
// sensitive unless the owner marked it below: a server that labels its
|
|
1015
|
+
// delete "read-only" would otherwise skip the question.
|
|
1016
|
+
trustAnnotations: z.boolean().optional(),
|
|
1017
|
+
// Remote tool names the owner marked as only reading. Read-only whatever the
|
|
1018
|
+
// remote says, trusted or not. Left optional rather than defaulted, so a
|
|
1019
|
+
// save that doesn't mention it keeps the stored marks.
|
|
1020
|
+
readOnlyTools: z.array(z.string().min(1).max(256)).max(500).optional(),
|
|
978
1021
|
timeoutMs: z
|
|
979
1022
|
.number()
|
|
980
1023
|
.int()
|
|
@@ -1247,6 +1290,53 @@ const ARTIFACT_CUSTOM_CODE_LIST_LOGS = z.object({
|
|
|
1247
1290
|
organizationId: z.uuid()
|
|
1248
1291
|
});
|
|
1249
1292
|
|
|
1293
|
+
// Tool observability on the project home: per-tool health over a window.
|
|
1294
|
+
const PROJECT_TOOL_HEALTH = z.object({
|
|
1295
|
+
days: z.coerce
|
|
1296
|
+
.number()
|
|
1297
|
+
.int()
|
|
1298
|
+
.refine(days => constants.OBSERVABILITY_RANGE_DAYS.includes(days), {
|
|
1299
|
+
message: 'Range is not valid'
|
|
1300
|
+
})
|
|
1301
|
+
.default(7),
|
|
1302
|
+
projectId: z.uuid(),
|
|
1303
|
+
userId: z.uuid(),
|
|
1304
|
+
organizationId: z.uuid()
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
// The calls behind a row of that table, newest first. Paged by the id of the
|
|
1308
|
+
// last call shown, not its time: calls written in one insert share a
|
|
1309
|
+
// timestamp, and the client only sees it to the millisecond.
|
|
1310
|
+
const PROJECT_TOOL_CALLS = z.object({
|
|
1311
|
+
tool: z.string().min(1).max(256).optional(),
|
|
1312
|
+
status: z.enum(['all', 'error']).default('all'),
|
|
1313
|
+
limit: z.coerce
|
|
1314
|
+
.number()
|
|
1315
|
+
.int()
|
|
1316
|
+
.min(1)
|
|
1317
|
+
.max(constants.OBSERVABILITY_CALLS_MAX_LIMIT)
|
|
1318
|
+
.default(constants.OBSERVABILITY_CALLS_DEFAULT_LIMIT),
|
|
1319
|
+
after: z.uuid().optional(),
|
|
1320
|
+
projectId: z.uuid(),
|
|
1321
|
+
userId: z.uuid(),
|
|
1322
|
+
organizationId: z.uuid()
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
// One recorded call, whole, and the session timeline it belongs to.
|
|
1326
|
+
const PROJECT_TOOL_CALL = z.object({
|
|
1327
|
+
requestId: z.uuid(),
|
|
1328
|
+
projectId: z.uuid(),
|
|
1329
|
+
userId: z.uuid(),
|
|
1330
|
+
organizationId: z.uuid()
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
const PROJECT_MCP_SESSION = z.object({
|
|
1334
|
+
sessionId: z.uuid(),
|
|
1335
|
+
projectId: z.uuid(),
|
|
1336
|
+
userId: z.uuid(),
|
|
1337
|
+
organizationId: z.uuid()
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1250
1340
|
// Reads the artifact's version history and which one is currently active.
|
|
1251
1341
|
const ARTIFACT_CUSTOM_CODE_LIST_VERSIONS = z.object({
|
|
1252
1342
|
projectId: z.uuid(),
|
|
@@ -1609,6 +1699,8 @@ export const Schema = {
|
|
|
1609
1699
|
PROJECT_INVITATION_REMOVE,
|
|
1610
1700
|
INVITATION_RESPOND,
|
|
1611
1701
|
INVITATION_GET_BY_TOKEN,
|
|
1702
|
+
TOOL_APPROVAL_GET,
|
|
1703
|
+
TOOL_APPROVAL_DECIDE,
|
|
1612
1704
|
ORGANIZATION_MEMBER_LIST,
|
|
1613
1705
|
ORGANIZATION_MEMBER_REMOVE,
|
|
1614
1706
|
PROJECT_MEMBER_LIST,
|
|
@@ -1688,6 +1780,10 @@ export const Schema = {
|
|
|
1688
1780
|
ARTIFACT_CUSTOM_CODE_ROLLBACK,
|
|
1689
1781
|
ARTIFACT_CUSTOM_CODE_LIST_LOGS,
|
|
1690
1782
|
ARTIFACT_CUSTOM_CODE_LIST_VERSIONS,
|
|
1783
|
+
PROJECT_TOOL_HEALTH,
|
|
1784
|
+
PROJECT_TOOL_CALLS,
|
|
1785
|
+
PROJECT_TOOL_CALL,
|
|
1786
|
+
PROJECT_MCP_SESSION,
|
|
1691
1787
|
CUSTOM_CODE_INVOKE_REQUEST,
|
|
1692
1788
|
CUSTOM_CODE_INVOKE_RESPONSE,
|
|
1693
1789
|
CUSTOM_CODE_BROKER_CONNECTION,
|