@qilin-agent/tool-skill 0.0.0-stage → 3.1.4
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/LICENSE +21 -0
- package/README.i18n.yaml +136 -0
- package/README.md +256 -2
- package/README.zh.md +257 -0
- package/lib/index.js +1122 -0
- package/lib/types/index.d.ts +46 -0
- package/package.json +52 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import z from "@qilin-agent/schemastery";
|
|
3
|
+
import { defineTool } from "@qilin-agent/tools";
|
|
4
|
+
import { createUserMessage } from "@qilin-agent/llm";
|
|
5
|
+
import "@qilin-agent/kylin";
|
|
6
|
+
import "node:path";
|
|
7
|
+
import { escapeText, isModelInvocable, isSkillName, isUserInvocable, renderSkillContent } from "@qilin-agent/skill";
|
|
8
|
+
//#region ../../util/brand/src/index.ts
|
|
9
|
+
/**
|
|
10
|
+
* Apply a compile-time number brand without changing the value.
|
|
11
|
+
* @param value - number admitted by the domain that owns the target brand.
|
|
12
|
+
* @returns the same number with the requested compile-time brand.
|
|
13
|
+
*/
|
|
14
|
+
function brandNumber(value) {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region ../../util/values/src/partial-json.ts
|
|
19
|
+
/**
|
|
20
|
+
* Lazily scanned view of one JSON object's top-level fields, built from text
|
|
21
|
+
* that may still be streaming or from an already parsed object. Nothing is
|
|
22
|
+
* scanned until a reader asks; the view remembers every question it answered
|
|
23
|
+
* and reports changed answers when the owner refreshes for publication.
|
|
24
|
+
* Used for model tool-call arguments: a row reads the fields it
|
|
25
|
+
* cares about at whatever granularity it displays, at every stage of the call.
|
|
26
|
+
* @module @qilin-agent/util-values/src/partial-json
|
|
27
|
+
*/
|
|
28
|
+
const SIMPLE_ESCAPES = {
|
|
29
|
+
"\"": "\"",
|
|
30
|
+
"\\": "\\",
|
|
31
|
+
"/": "/",
|
|
32
|
+
b: "\b",
|
|
33
|
+
f: "\f",
|
|
34
|
+
n: "\n",
|
|
35
|
+
r: "\r",
|
|
36
|
+
t: " "
|
|
37
|
+
};
|
|
38
|
+
const CONTENT_ESCAPE = /[\\\u0000-\u001f]/u;
|
|
39
|
+
function isWhitespace(c) {
|
|
40
|
+
return c === " " || c === "\n" || c === "\r" || c === " ";
|
|
41
|
+
}
|
|
42
|
+
function isHex(c) {
|
|
43
|
+
return c >= "0" && c <= "9" || c >= "a" && c <= "f" || c >= "A" && c <= "F";
|
|
44
|
+
}
|
|
45
|
+
(class PartialArguments {
|
|
46
|
+
/** The view of a call with no arguments available. */
|
|
47
|
+
static EMPTY = PartialArguments.fromObject({});
|
|
48
|
+
/**
|
|
49
|
+
* View finished argument text without scanning it until a reader asks.
|
|
50
|
+
* @param text - the complete argument JSON text.
|
|
51
|
+
* @returns a sealed view.
|
|
52
|
+
*/
|
|
53
|
+
static fromText(text) {
|
|
54
|
+
const view = new PartialArguments();
|
|
55
|
+
view.append(text);
|
|
56
|
+
view.sealed = true;
|
|
57
|
+
return view;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* View an already parsed argument payload, such as a PTC dispatch object.
|
|
61
|
+
* @param value - the parsed argument value.
|
|
62
|
+
* @returns a sealed view; a non-object payload has no fields.
|
|
63
|
+
*/
|
|
64
|
+
static fromObject(value) {
|
|
65
|
+
const view = new PartialArguments();
|
|
66
|
+
view.object = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
67
|
+
view.sealed = true;
|
|
68
|
+
return view;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The source: text so far or a parsed object, plus whether it can still grow.
|
|
72
|
+
* These are the only enumerable fields, so two views over the same source
|
|
73
|
+
* compare equal structurally however far each has been read.
|
|
74
|
+
*/
|
|
75
|
+
chunks = [];
|
|
76
|
+
object;
|
|
77
|
+
sealed = false;
|
|
78
|
+
#ends = [];
|
|
79
|
+
#size = 0;
|
|
80
|
+
#consumed = 0;
|
|
81
|
+
#mode = "root";
|
|
82
|
+
#escape = false;
|
|
83
|
+
#keyStart = 0;
|
|
84
|
+
#keyEscaped = false;
|
|
85
|
+
#key = "";
|
|
86
|
+
#current = null;
|
|
87
|
+
#nestedEnds = [];
|
|
88
|
+
#nestedInString = false;
|
|
89
|
+
#invalidAt;
|
|
90
|
+
#invalidValue = false;
|
|
91
|
+
#entries = /* @__PURE__ */ new Map();
|
|
92
|
+
#order = [];
|
|
93
|
+
#reads = /* @__PURE__ */ new Map();
|
|
94
|
+
/** Whether this view rejects further appends; does not scan text or register reads. */
|
|
95
|
+
get isSealed() {
|
|
96
|
+
return this.sealed;
|
|
97
|
+
}
|
|
98
|
+
/** Whether indexing or a content read found invalid JSON; unread value contents are not validated. */
|
|
99
|
+
get invalid() {
|
|
100
|
+
this.scan();
|
|
101
|
+
return this.#mode === "invalid" || this.#invalidValue;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Retain streamed argument text without scanning or comparing observed answers.
|
|
105
|
+
* @param fragment - the text following every fragment appended before.
|
|
106
|
+
*/
|
|
107
|
+
append(fragment) {
|
|
108
|
+
if (this.sealed) throw new Error("PartialArguments: cannot append to a sealed view");
|
|
109
|
+
if (fragment.length === 0) return;
|
|
110
|
+
this.chunks.push(fragment);
|
|
111
|
+
this.#size += fragment.length;
|
|
112
|
+
this.#ends.push(this.#size);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Reconcile a streamed prefix with authoritative complete text without joining the fragments.
|
|
116
|
+
* @param text - the final argument text, which replaces missing or conflicting deltas.
|
|
117
|
+
* @returns this view sealed with its caches retained when every character matches; otherwise a new sealed view.
|
|
118
|
+
*/
|
|
119
|
+
settle(text) {
|
|
120
|
+
if (this.object !== void 0 || text.length !== this.#size) return PartialArguments.fromText(text);
|
|
121
|
+
let offset = 0;
|
|
122
|
+
for (const chunk of this.chunks) {
|
|
123
|
+
if (!text.startsWith(chunk, offset)) return PartialArguments.fromText(text);
|
|
124
|
+
offset += chunk.length;
|
|
125
|
+
}
|
|
126
|
+
this.chunks = text.length === 0 ? [] : [text];
|
|
127
|
+
this.#ends = text.length === 0 ? [] : [text.length];
|
|
128
|
+
this.sealed = true;
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Compare observed answers and advance their publication baseline. Unread views remain unscanned.
|
|
133
|
+
* @returns whether any observed answer changed since its first read or the preceding refresh.
|
|
134
|
+
*/
|
|
135
|
+
refresh() {
|
|
136
|
+
if (this.#reads.size === 0) return false;
|
|
137
|
+
this.scan();
|
|
138
|
+
let changed = false;
|
|
139
|
+
let completions = false;
|
|
140
|
+
for (const read of this.#reads.values()) {
|
|
141
|
+
if (read.completion) {
|
|
142
|
+
completions = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
changed = this.refreshRead(read) || changed;
|
|
146
|
+
}
|
|
147
|
+
if (completions) {
|
|
148
|
+
for (const read of this.#reads.values()) if (read.completion) changed = this.refreshRead(read) || changed;
|
|
149
|
+
}
|
|
150
|
+
if (this.sealed) this.#reads.clear();
|
|
151
|
+
return changed;
|
|
152
|
+
}
|
|
153
|
+
refreshRead(read) {
|
|
154
|
+
const now = read.answer();
|
|
155
|
+
if (Object.is(now, read.last)) return false;
|
|
156
|
+
read.last = now;
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Check whether no further fields can arrive.
|
|
161
|
+
* @returns whether the outer object closed, indexing failed, or the view is sealed; unread values are not validated.
|
|
162
|
+
*/
|
|
163
|
+
closed() {
|
|
164
|
+
return this.remember("closed", "", () => this.closedNow());
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* List discovered fields in first-appearance order.
|
|
168
|
+
* @returns top-level keys seen so far, in first-appearance order.
|
|
169
|
+
*/
|
|
170
|
+
keys() {
|
|
171
|
+
return this.remember("keys", "", () => this.keysNow(), (keys) => keys.length);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Check whether a top-level field has appeared.
|
|
175
|
+
* @param key - argument name.
|
|
176
|
+
* @returns whether the field has appeared (a string opened or another value began).
|
|
177
|
+
*/
|
|
178
|
+
has(key) {
|
|
179
|
+
return this.remember("has", key, () => this.hasNow(key));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Check whether a field's closing delimiter has arrived, without validating its contents.
|
|
183
|
+
* @param key - argument name.
|
|
184
|
+
* @returns whether its delimiter arrived and no content reader has reported an error for this value.
|
|
185
|
+
*/
|
|
186
|
+
complete(key) {
|
|
187
|
+
return this.remember("complete", key, () => this.completeNow(key));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Read string length without materializing its text.
|
|
191
|
+
* @param key - argument name.
|
|
192
|
+
* @param options - change granularity for a streaming string.
|
|
193
|
+
* @returns decoded UTF-16 length of the string field so far; undefined when absent or not a string.
|
|
194
|
+
*/
|
|
195
|
+
stringLength(key, options) {
|
|
196
|
+
const step = Math.max(1, Math.floor(options?.step ?? 1));
|
|
197
|
+
const offset = options?.offset ?? 0;
|
|
198
|
+
return this.remember(`length:${step}:${offset}`, key, () => this.lengthNow(key), (length) => length === void 0 ? void 0 : Math.ceil((length + offset) / step));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Check a string against a decoded UTF-16 length limit without materializing it.
|
|
202
|
+
* @param key - argument name.
|
|
203
|
+
* @param maxLength - decoded UTF-16 limit, floored to at least zero.
|
|
204
|
+
* @returns whether the string is longer than the limit; false when absent or not a string.
|
|
205
|
+
*/
|
|
206
|
+
stringExceeds(key, maxLength) {
|
|
207
|
+
const limit = Math.max(0, Math.floor(maxLength));
|
|
208
|
+
return this.remember(`exceeds:${limit}`, key, () => (this.lengthNow(key, limit + 1) ?? 0) > limit);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Read a decoded string, including a streaming prefix.
|
|
212
|
+
* @param key - argument name.
|
|
213
|
+
* @returns the string field's decoded text so far; undefined when absent or not a string.
|
|
214
|
+
*/
|
|
215
|
+
text(key) {
|
|
216
|
+
return this.remember("text", key, () => this.textNow(key));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Read at most the first decoded UTF-16 units of a string.
|
|
220
|
+
* @param key - argument name.
|
|
221
|
+
* @param maxLength - maximum decoded UTF-16 length, floored to at least one.
|
|
222
|
+
* @returns the bounded string prefix; undefined when absent or not a string.
|
|
223
|
+
*/
|
|
224
|
+
textPrefix(key, maxLength) {
|
|
225
|
+
const limit = Math.max(1, Math.floor(maxLength));
|
|
226
|
+
return this.remember(`prefix:${limit}`, key, () => this.textPrefixNow(key, limit));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Read a completed non-string argument.
|
|
230
|
+
* @param key - argument name.
|
|
231
|
+
* @returns the parsed non-string value once it closed; undefined while open, absent, or a string.
|
|
232
|
+
*/
|
|
233
|
+
value(key) {
|
|
234
|
+
return this.remember("value", key, () => this.valueNow(key));
|
|
235
|
+
}
|
|
236
|
+
/** Answer a question and, on a streaming view, remember it for change detection. */
|
|
237
|
+
remember(kind, key, read, comparison) {
|
|
238
|
+
this.scan();
|
|
239
|
+
const result = read();
|
|
240
|
+
if (!this.sealed) {
|
|
241
|
+
const id = `${kind}/${key}`;
|
|
242
|
+
if (!this.#reads.has(id)) this.#reads.set(id, {
|
|
243
|
+
completion: kind === "complete",
|
|
244
|
+
answer: comparison === void 0 ? read : () => comparison(read()),
|
|
245
|
+
last: comparison === void 0 ? result : comparison(result)
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
closedNow() {
|
|
251
|
+
return this.sealed || this.#mode === "closed" || this.#mode === "invalid";
|
|
252
|
+
}
|
|
253
|
+
keysNow() {
|
|
254
|
+
return this.object === void 0 ? this.#order : Object.keys(this.object);
|
|
255
|
+
}
|
|
256
|
+
hasNow(key) {
|
|
257
|
+
return this.object === void 0 ? this.#entries.has(key) : Object.hasOwn(this.object, key);
|
|
258
|
+
}
|
|
259
|
+
completeNow(key) {
|
|
260
|
+
if (this.object !== void 0) return Object.hasOwn(this.object, key);
|
|
261
|
+
const entry = this.#entries.get(key);
|
|
262
|
+
return entry !== void 0 && entry.end >= 0 && (entry.kind === "string" ? entry.invalidAt === void 0 : !entry.invalid);
|
|
263
|
+
}
|
|
264
|
+
lengthNow(key, limit = Number.POSITIVE_INFINITY) {
|
|
265
|
+
if (this.object !== void 0) {
|
|
266
|
+
const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
|
|
267
|
+
return typeof field === "string" ? field.length : void 0;
|
|
268
|
+
}
|
|
269
|
+
const entry = this.#entries.get(key);
|
|
270
|
+
if (entry?.kind !== "string") return void 0;
|
|
271
|
+
if (entry.text !== void 0 && entry.text.at === entry.end) return entry.text.length;
|
|
272
|
+
const read = entry.length ??= {
|
|
273
|
+
at: entry.start,
|
|
274
|
+
length: 0,
|
|
275
|
+
text: ""
|
|
276
|
+
};
|
|
277
|
+
this.readString(entry, read, limit, false);
|
|
278
|
+
return read.length;
|
|
279
|
+
}
|
|
280
|
+
textNow(key) {
|
|
281
|
+
if (this.object !== void 0) {
|
|
282
|
+
const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
|
|
283
|
+
return typeof field === "string" ? field : void 0;
|
|
284
|
+
}
|
|
285
|
+
const entry = this.#entries.get(key);
|
|
286
|
+
if (entry?.kind !== "string") return void 0;
|
|
287
|
+
if (entry.text === void 0 && entry.end >= 0 && entry.needsDecoding && entry.invalidAt === void 0) {
|
|
288
|
+
let text;
|
|
289
|
+
try {
|
|
290
|
+
text = JSON.parse(`"${this.slice(entry.start, entry.end)}"`);
|
|
291
|
+
} catch (_error) {}
|
|
292
|
+
if (text !== void 0) entry.text = {
|
|
293
|
+
at: entry.end,
|
|
294
|
+
length: text.length,
|
|
295
|
+
text
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const read = entry.text ??= {
|
|
299
|
+
at: entry.start,
|
|
300
|
+
length: 0,
|
|
301
|
+
text: ""
|
|
302
|
+
};
|
|
303
|
+
this.readString(entry, read, Number.POSITIVE_INFINITY, true);
|
|
304
|
+
return read.text;
|
|
305
|
+
}
|
|
306
|
+
textPrefixNow(key, maxLength) {
|
|
307
|
+
if (this.object !== void 0) {
|
|
308
|
+
const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
|
|
309
|
+
return typeof field === "string" ? field.slice(0, maxLength) : void 0;
|
|
310
|
+
}
|
|
311
|
+
const entry = this.#entries.get(key);
|
|
312
|
+
if (entry?.kind !== "string") return void 0;
|
|
313
|
+
const prefixes = entry.prefixes ??= /* @__PURE__ */ new Map();
|
|
314
|
+
let read = prefixes.get(maxLength);
|
|
315
|
+
if (read === void 0) {
|
|
316
|
+
read = {
|
|
317
|
+
at: entry.start,
|
|
318
|
+
length: 0,
|
|
319
|
+
text: ""
|
|
320
|
+
};
|
|
321
|
+
prefixes.set(maxLength, read);
|
|
322
|
+
}
|
|
323
|
+
this.readString(entry, read, maxLength, true);
|
|
324
|
+
return read.text;
|
|
325
|
+
}
|
|
326
|
+
valueNow(key) {
|
|
327
|
+
if (this.object !== void 0) {
|
|
328
|
+
if (!Object.hasOwn(this.object, key)) return void 0;
|
|
329
|
+
const field = this.object[key];
|
|
330
|
+
return typeof field === "string" ? void 0 : field;
|
|
331
|
+
}
|
|
332
|
+
const entry = this.#entries.get(key);
|
|
333
|
+
if (entry?.kind !== "value" || entry.end < 0 || entry.invalid) return void 0;
|
|
334
|
+
if (entry.parsed === void 0) try {
|
|
335
|
+
entry.parsed = JSON.parse(this.slice(entry.start, entry.end));
|
|
336
|
+
} catch (_error) {
|
|
337
|
+
entry.invalid = true;
|
|
338
|
+
this.#invalidValue = true;
|
|
339
|
+
}
|
|
340
|
+
return entry.parsed;
|
|
341
|
+
}
|
|
342
|
+
chunkAt(at) {
|
|
343
|
+
let low = 0;
|
|
344
|
+
let high = this.#ends.length;
|
|
345
|
+
while (low < high) {
|
|
346
|
+
const mid = low + high >>> 1;
|
|
347
|
+
if (this.#ends[mid] <= at) low = mid + 1;
|
|
348
|
+
else high = mid;
|
|
349
|
+
}
|
|
350
|
+
return low;
|
|
351
|
+
}
|
|
352
|
+
/** Materialize only a requested range, never the cumulative source. */
|
|
353
|
+
slice(start, end) {
|
|
354
|
+
if (start >= end) return "";
|
|
355
|
+
const first = this.chunkAt(start);
|
|
356
|
+
const last = this.chunkAt(end - 1);
|
|
357
|
+
const base = first === 0 ? 0 : this.#ends[first - 1];
|
|
358
|
+
if (first === last) return this.chunks[first].slice(start - base, end - base);
|
|
359
|
+
const parts = [this.chunks[first].slice(start - base)];
|
|
360
|
+
for (let i = first + 1; i < last; i++) parts.push(this.chunks[i]);
|
|
361
|
+
parts.push(this.chunks[last].slice(0, end - this.#ends[last - 1]));
|
|
362
|
+
return parts.join("");
|
|
363
|
+
}
|
|
364
|
+
readString(entry, read, limit, materialize) {
|
|
365
|
+
const end = Math.min(entry.end < 0 ? this.#consumed : entry.end, entry.invalidAt ?? Number.POSITIVE_INFINITY, this.#invalidAt ?? Number.POSITIVE_INFINITY);
|
|
366
|
+
if (!entry.needsDecoding) {
|
|
367
|
+
const length = Math.min(end - read.at, limit - read.length);
|
|
368
|
+
if (length <= 0) return;
|
|
369
|
+
if (materialize) read.text += this.slice(read.at, read.at + length);
|
|
370
|
+
read.at += length;
|
|
371
|
+
read.length += length;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
let chunkIndex = this.chunkAt(read.at);
|
|
375
|
+
while (read.at < end && read.length < limit) {
|
|
376
|
+
const base = chunkIndex === 0 ? 0 : this.#ends[chunkIndex - 1];
|
|
377
|
+
const chunk = this.chunks[chunkIndex];
|
|
378
|
+
const remaining = chunk.slice(read.at - base, Math.min(chunk.length, end - base));
|
|
379
|
+
const boundary = remaining.search(CONTENT_ESCAPE);
|
|
380
|
+
const length = Math.min(boundary < 0 ? remaining.length : boundary, limit - read.length);
|
|
381
|
+
if (length > 0) {
|
|
382
|
+
if (materialize) read.text += remaining.slice(0, length);
|
|
383
|
+
read.at += length;
|
|
384
|
+
read.length += length;
|
|
385
|
+
if (read.at === base + chunk.length) chunkIndex++;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const type = remaining.length > 1 ? remaining[1] : read.at + 1 < end ? this.chunks[chunkIndex + 1][0] : void 0;
|
|
389
|
+
let decoded;
|
|
390
|
+
let width = 2;
|
|
391
|
+
if (remaining[0] === "\\" && type === void 0 && entry.end < 0) return;
|
|
392
|
+
if (remaining[0] === "\\" && type === "u") {
|
|
393
|
+
const hex = this.slice(read.at + 2, Math.min(end, read.at + 6));
|
|
394
|
+
let valid = true;
|
|
395
|
+
for (let i = 0; i < hex.length; i++) if (!isHex(hex[i])) valid = false;
|
|
396
|
+
if (valid) {
|
|
397
|
+
if (hex.length < 4 && entry.end < 0) return;
|
|
398
|
+
if (hex.length === 4) decoded = String.fromCharCode(Number.parseInt(hex, 16));
|
|
399
|
+
}
|
|
400
|
+
width = 6;
|
|
401
|
+
} else if (remaining[0] === "\\" && type !== void 0) decoded = SIMPLE_ESCAPES[type];
|
|
402
|
+
if (decoded === void 0) {
|
|
403
|
+
entry.invalidAt = read.at;
|
|
404
|
+
this.#invalidValue = true;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (materialize) read.text += decoded;
|
|
408
|
+
read.length++;
|
|
409
|
+
read.at += width;
|
|
410
|
+
while (chunkIndex < this.chunks.length && read.at >= this.#ends[chunkIndex]) chunkIndex++;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** Locate new field ranges without decoding or parsing their contents. */
|
|
414
|
+
scan() {
|
|
415
|
+
if (this.object !== void 0 || this.#consumed === this.#size) return;
|
|
416
|
+
for (let i = this.chunkAt(this.#consumed); i < this.chunks.length && this.#invalidAt === void 0; i++) {
|
|
417
|
+
const pending = this.chunks[i];
|
|
418
|
+
const base = i === 0 ? 0 : this.#ends[i - 1];
|
|
419
|
+
for (let index = this.#consumed - base; index < pending.length && this.#mode !== "invalid"; index++) {
|
|
420
|
+
if (this.#mode === "string" || this.#mode === "nested" && this.#nestedInString) {
|
|
421
|
+
const end = this.stringBoundary(pending, index);
|
|
422
|
+
this.#consumed += end - index;
|
|
423
|
+
index = end;
|
|
424
|
+
if (index === pending.length) break;
|
|
425
|
+
}
|
|
426
|
+
this.step(pending[index], this.#consumed);
|
|
427
|
+
this.#consumed++;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/** Only raw quotes and their preceding backslash runs can terminate a string. */
|
|
432
|
+
stringBoundary(fragment, start) {
|
|
433
|
+
let at = start;
|
|
434
|
+
while (true) {
|
|
435
|
+
const quote = fragment.indexOf("\"", at);
|
|
436
|
+
const end = quote < 0 ? fragment.length : quote;
|
|
437
|
+
if (this.#mode === "string") {
|
|
438
|
+
const entry = this.#current;
|
|
439
|
+
if (!entry.needsDecoding && CONTENT_ESCAPE.test(fragment.slice(at, end))) entry.needsDecoding = true;
|
|
440
|
+
}
|
|
441
|
+
let slashStart = end;
|
|
442
|
+
while (slashStart > at && fragment[slashStart - 1] === "\\") slashStart--;
|
|
443
|
+
const escaped = (end - slashStart) % 2 === 1 !== (slashStart === at && this.#escape);
|
|
444
|
+
this.#escape = quote < 0 && escaped;
|
|
445
|
+
if (quote < 0 || !escaped) return end;
|
|
446
|
+
at = quote + 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
step(c, at) {
|
|
450
|
+
switch (this.#mode) {
|
|
451
|
+
case "root":
|
|
452
|
+
if (isWhitespace(c)) return;
|
|
453
|
+
if (c === "{") {
|
|
454
|
+
this.#mode = "key-or-end";
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
this.fail();
|
|
458
|
+
return;
|
|
459
|
+
case "key-or-end":
|
|
460
|
+
if (isWhitespace(c)) return;
|
|
461
|
+
if (c === "}") {
|
|
462
|
+
this.#mode = "closed";
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (c === "\"") {
|
|
466
|
+
this.beginKey(at);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.fail();
|
|
470
|
+
return;
|
|
471
|
+
case "key-only":
|
|
472
|
+
if (isWhitespace(c)) return;
|
|
473
|
+
if (c === "\"") {
|
|
474
|
+
this.beginKey(at);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
this.fail();
|
|
478
|
+
return;
|
|
479
|
+
case "key":
|
|
480
|
+
this.stepKey(c, at);
|
|
481
|
+
return;
|
|
482
|
+
case "colon":
|
|
483
|
+
if (isWhitespace(c)) return;
|
|
484
|
+
if (c === ":") {
|
|
485
|
+
this.#mode = "value";
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this.fail();
|
|
489
|
+
return;
|
|
490
|
+
case "value":
|
|
491
|
+
this.beginValue(c, at);
|
|
492
|
+
return;
|
|
493
|
+
case "string": {
|
|
494
|
+
const entry = this.#current;
|
|
495
|
+
entry.end = at;
|
|
496
|
+
this.#current = null;
|
|
497
|
+
this.#mode = "comma-or-end";
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
case "scalar":
|
|
501
|
+
this.stepScalar(c, at);
|
|
502
|
+
return;
|
|
503
|
+
case "nested":
|
|
504
|
+
this.stepNested(c, at);
|
|
505
|
+
return;
|
|
506
|
+
case "comma-or-end":
|
|
507
|
+
if (isWhitespace(c)) return;
|
|
508
|
+
if (c === ",") {
|
|
509
|
+
this.#mode = "key-only";
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (c === "}") {
|
|
513
|
+
this.#mode = "closed";
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.fail();
|
|
517
|
+
return;
|
|
518
|
+
case "closed":
|
|
519
|
+
if (isWhitespace(c)) return;
|
|
520
|
+
this.fail();
|
|
521
|
+
return;
|
|
522
|
+
/* v8 ignore next 2 -- scan() stops stepping once the view is invalid. */
|
|
523
|
+
case "invalid": return;
|
|
524
|
+
/* v8 ignore next 2 -- Every scanner mode has a handler above. */
|
|
525
|
+
default: assertNever(this.#mode);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
fail() {
|
|
529
|
+
this.#invalidAt = this.#consumed;
|
|
530
|
+
this.#mode = "invalid";
|
|
531
|
+
this.#current = null;
|
|
532
|
+
}
|
|
533
|
+
beginKey(at) {
|
|
534
|
+
this.#mode = "key";
|
|
535
|
+
this.#keyStart = at + 1;
|
|
536
|
+
this.#keyEscaped = false;
|
|
537
|
+
this.#escape = false;
|
|
538
|
+
}
|
|
539
|
+
stepKey(c, at) {
|
|
540
|
+
if (c < " ") {
|
|
541
|
+
this.fail();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (this.#escape) {
|
|
545
|
+
this.#escape = false;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (c === "\\") {
|
|
549
|
+
this.#escape = true;
|
|
550
|
+
this.#keyEscaped = true;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (c !== "\"") return;
|
|
554
|
+
const raw = this.slice(this.#keyStart, at);
|
|
555
|
+
if (this.#keyEscaped) try {
|
|
556
|
+
this.#key = JSON.parse(`"${raw}"`);
|
|
557
|
+
} catch (_error) {
|
|
558
|
+
this.fail();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
else this.#key = raw;
|
|
562
|
+
this.#mode = "colon";
|
|
563
|
+
}
|
|
564
|
+
open(entry) {
|
|
565
|
+
if (!this.#entries.has(this.#key)) this.#order.push(this.#key);
|
|
566
|
+
this.#entries.set(this.#key, entry);
|
|
567
|
+
this.#current = entry;
|
|
568
|
+
}
|
|
569
|
+
beginValue(c, at) {
|
|
570
|
+
if (isWhitespace(c)) return;
|
|
571
|
+
if (c === "\"") {
|
|
572
|
+
this.open({
|
|
573
|
+
kind: "string",
|
|
574
|
+
start: at + 1,
|
|
575
|
+
end: -1,
|
|
576
|
+
needsDecoding: false,
|
|
577
|
+
invalidAt: void 0,
|
|
578
|
+
length: void 0,
|
|
579
|
+
text: void 0,
|
|
580
|
+
prefixes: void 0
|
|
581
|
+
});
|
|
582
|
+
this.#escape = false;
|
|
583
|
+
this.#mode = "string";
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (c === "}" || c === "," || c === ":" || c === "]") {
|
|
587
|
+
this.fail();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
this.open({
|
|
591
|
+
kind: "value",
|
|
592
|
+
start: at,
|
|
593
|
+
end: -1,
|
|
594
|
+
parsed: void 0,
|
|
595
|
+
invalid: false
|
|
596
|
+
});
|
|
597
|
+
if (c === "{" || c === "[") {
|
|
598
|
+
this.#mode = "nested";
|
|
599
|
+
this.#nestedEnds = [c === "{" ? "}" : "]"];
|
|
600
|
+
this.#nestedInString = false;
|
|
601
|
+
this.#escape = false;
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
this.#mode = "scalar";
|
|
605
|
+
}
|
|
606
|
+
stepScalar(c, at) {
|
|
607
|
+
if (c !== "," && c !== "}" && !isWhitespace(c)) return;
|
|
608
|
+
this.closeValue(at);
|
|
609
|
+
this.#mode = c === "," ? "key-only" : c === "}" ? "closed" : "comma-or-end";
|
|
610
|
+
}
|
|
611
|
+
stepNested(c, at) {
|
|
612
|
+
if (this.#nestedInString) {
|
|
613
|
+
this.#nestedInString = false;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (c === "\"") {
|
|
617
|
+
this.#nestedInString = true;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (c === "{" || c === "[") {
|
|
621
|
+
this.#nestedEnds.push(c === "{" ? "}" : "]");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (c === "}" || c === "]") {
|
|
625
|
+
if (this.#nestedEnds.pop() !== c) {
|
|
626
|
+
this.fail();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (this.#nestedEnds.length === 0) {
|
|
630
|
+
this.closeValue(at + 1);
|
|
631
|
+
this.#mode = "comma-or-end";
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
closeValue(end) {
|
|
636
|
+
const entry = this.#current;
|
|
637
|
+
entry.end = end;
|
|
638
|
+
this.#current = null;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region ../../util/values/src/index.ts
|
|
643
|
+
/**
|
|
644
|
+
* Mark an unreachable closed-union branch.
|
|
645
|
+
* @param value - impossible value; an unhandled typed variant fails at the call site.
|
|
646
|
+
* @param context - optional switch-site label included in the failure message.
|
|
647
|
+
* @returns never; a runtime value that escaped its type always throws.
|
|
648
|
+
*/
|
|
649
|
+
function assertNever(value, context) {
|
|
650
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
651
|
+
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region ../../core/session/src/types.ts
|
|
655
|
+
/**
|
|
656
|
+
* Admit a numeric value as an existing Session event position.
|
|
657
|
+
* @param value - non-negative safe integer admitted by the owning log operation.
|
|
658
|
+
* @returns the same number with the Session-sequence brand.
|
|
659
|
+
*/
|
|
660
|
+
function SessionSeq(value) {
|
|
661
|
+
if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) throw new TypeError(`SessionSeq must be a non-negative safe integer, got ${String(value)}`);
|
|
662
|
+
return brandNumber(value);
|
|
663
|
+
}
|
|
664
|
+
new Set([
|
|
665
|
+
"agent-preset/selected",
|
|
666
|
+
"agent/inbox/spliced",
|
|
667
|
+
"approval/asked",
|
|
668
|
+
"approval/decided",
|
|
669
|
+
"approval/policy",
|
|
670
|
+
"assistant/attempt",
|
|
671
|
+
"assistant/message",
|
|
672
|
+
"command/done",
|
|
673
|
+
"command/run",
|
|
674
|
+
"compaction/end",
|
|
675
|
+
"compaction/prune",
|
|
676
|
+
"compaction/start",
|
|
677
|
+
"compaction/summary",
|
|
678
|
+
"deliverables/presented",
|
|
679
|
+
"developer/message",
|
|
680
|
+
"feedback/message-delete",
|
|
681
|
+
"feedback/message-put",
|
|
682
|
+
"feedback/record",
|
|
683
|
+
"goal/change",
|
|
684
|
+
"hook/invoked",
|
|
685
|
+
"hook/result",
|
|
686
|
+
"image/offload",
|
|
687
|
+
"llm/retry",
|
|
688
|
+
"llm/retry-started",
|
|
689
|
+
"model/selection",
|
|
690
|
+
"permission/preset",
|
|
691
|
+
"plan/mode",
|
|
692
|
+
"request/context",
|
|
693
|
+
"request/header",
|
|
694
|
+
"sandbox/mode",
|
|
695
|
+
"schedule/change",
|
|
696
|
+
"session-log-deepseek/delivery-accepted",
|
|
697
|
+
"session/end-seed",
|
|
698
|
+
"session/title",
|
|
699
|
+
"session/title-llm-request",
|
|
700
|
+
"step/end",
|
|
701
|
+
"step/start",
|
|
702
|
+
"subagent/catalog",
|
|
703
|
+
"subagent/descriptor",
|
|
704
|
+
"subagent/model-selection-policy",
|
|
705
|
+
"system/message",
|
|
706
|
+
"team/member",
|
|
707
|
+
"team/message/delivered",
|
|
708
|
+
"team/message/queued",
|
|
709
|
+
"team/task",
|
|
710
|
+
"todo/write",
|
|
711
|
+
"tool-workflow/agent-end",
|
|
712
|
+
"tool-workflow/agent-start",
|
|
713
|
+
"tool-workflow/run-end",
|
|
714
|
+
"tool-workflow/run-start",
|
|
715
|
+
"tool/call",
|
|
716
|
+
"tool/ptc-dispatch",
|
|
717
|
+
"tool/ptc-dispatch-start",
|
|
718
|
+
"tool/result",
|
|
719
|
+
"turn/end",
|
|
720
|
+
"turn/start",
|
|
721
|
+
"user/message",
|
|
722
|
+
"web/deepseek-search-llm-request",
|
|
723
|
+
"working-directory/change",
|
|
724
|
+
"workspace/changes"
|
|
725
|
+
]);
|
|
726
|
+
new Set(["image/offload"]);
|
|
727
|
+
new Set([
|
|
728
|
+
"system/message",
|
|
729
|
+
"developer/message",
|
|
730
|
+
"user/message",
|
|
731
|
+
"assistant/message",
|
|
732
|
+
"tool/result"
|
|
733
|
+
]);
|
|
734
|
+
/**
|
|
735
|
+
* Deep structural equality over the session-event JSON value domain
|
|
736
|
+
* (null/boolean/number/string, arrays, plain objects). Replaces
|
|
737
|
+
* `node:util`'s isDeepStrictEqual to keep this module browser-safe.
|
|
738
|
+
*/
|
|
739
|
+
function isDeepEqualJson(a, b) {
|
|
740
|
+
if (a === b) return true;
|
|
741
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
742
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
743
|
+
return a.every((item, i) => isDeepEqualJson(item, b[i]));
|
|
744
|
+
}
|
|
745
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
746
|
+
const aKeys = Object.keys(a);
|
|
747
|
+
const bRecord = b;
|
|
748
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
749
|
+
return aKeys.every((key) => Object.hasOwn(b, key) && isDeepEqualJson(a[key], bRecord[key]));
|
|
750
|
+
}
|
|
751
|
+
new Set(["reasoningEffort", "maxTokens"]);
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region lib/types/index.js
|
|
754
|
+
/**
|
|
755
|
+
* Durable session skill catalog and model-facing `skill` loader tool.
|
|
756
|
+
*
|
|
757
|
+
* @module @qilin-agent/tool-skill
|
|
758
|
+
*/
|
|
759
|
+
const name = "tool-skill";
|
|
760
|
+
const inject = [
|
|
761
|
+
"agents",
|
|
762
|
+
"tools",
|
|
763
|
+
"skills",
|
|
764
|
+
"workingDirectory"
|
|
765
|
+
];
|
|
766
|
+
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500;
|
|
767
|
+
/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */
|
|
768
|
+
function catalogSourceEntries(skills, descriptionMaxLength) {
|
|
769
|
+
return skills.map((skill) => ({
|
|
770
|
+
name: skill.name,
|
|
771
|
+
description: catalogDescription(skill.description, descriptionMaxLength)
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
/** Validate and default the model-facing skill catalog configuration. */
|
|
775
|
+
const Config = z.object({ catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH) });
|
|
776
|
+
/**
|
|
777
|
+
* Register the model-facing skill loader and its visibility-matched
|
|
778
|
+
* durable session catalog. The catalog is emitted only when the calling agent
|
|
779
|
+
* resolves this plugin's exact tool registration; a restriction or scoped
|
|
780
|
+
* same-name shadow therefore removes both the schema and its call guidance.
|
|
781
|
+
*/
|
|
782
|
+
function apply(ctx, config = {}) {
|
|
783
|
+
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH;
|
|
784
|
+
assertPositiveInteger("catalogDescriptionMaxLength", catalogDescriptionMaxLength, 3);
|
|
785
|
+
const skillTool = defineTool({
|
|
786
|
+
name: "skill",
|
|
787
|
+
description: "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
|
|
788
|
+
parameters: { name: {
|
|
789
|
+
type: "string",
|
|
790
|
+
required: true,
|
|
791
|
+
description: "The exact skill name from the available skills list."
|
|
792
|
+
} },
|
|
793
|
+
output: {
|
|
794
|
+
schema: {
|
|
795
|
+
type: "object",
|
|
796
|
+
additionalProperties: false,
|
|
797
|
+
properties: {
|
|
798
|
+
name: {
|
|
799
|
+
type: "string",
|
|
800
|
+
required: true
|
|
801
|
+
},
|
|
802
|
+
provider: {
|
|
803
|
+
type: "string",
|
|
804
|
+
required: true
|
|
805
|
+
},
|
|
806
|
+
resourceBase: { oneOf: [
|
|
807
|
+
{
|
|
808
|
+
type: "object",
|
|
809
|
+
additionalProperties: false,
|
|
810
|
+
properties: {
|
|
811
|
+
kind: {
|
|
812
|
+
type: "string",
|
|
813
|
+
required: true,
|
|
814
|
+
const: "directory"
|
|
815
|
+
},
|
|
816
|
+
path: {
|
|
817
|
+
type: "string",
|
|
818
|
+
required: true
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
type: "object",
|
|
824
|
+
additionalProperties: false,
|
|
825
|
+
properties: {
|
|
826
|
+
kind: {
|
|
827
|
+
type: "string",
|
|
828
|
+
required: true,
|
|
829
|
+
const: "url"
|
|
830
|
+
},
|
|
831
|
+
url: {
|
|
832
|
+
type: "string",
|
|
833
|
+
required: true
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
type: "object",
|
|
839
|
+
additionalProperties: false,
|
|
840
|
+
properties: {
|
|
841
|
+
kind: {
|
|
842
|
+
type: "string",
|
|
843
|
+
required: true,
|
|
844
|
+
const: "opaque"
|
|
845
|
+
},
|
|
846
|
+
description: {
|
|
847
|
+
type: "string",
|
|
848
|
+
required: true
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
] },
|
|
853
|
+
content: {
|
|
854
|
+
type: "string",
|
|
855
|
+
required: true
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
render: (_args, value) => [{
|
|
860
|
+
type: "text",
|
|
861
|
+
text: renderSkillContent(value)
|
|
862
|
+
}]
|
|
863
|
+
},
|
|
864
|
+
async execute(args, exec) {
|
|
865
|
+
if (!isSkillName(args.name)) throw new Error(`invalid skill name "${args.name}"`);
|
|
866
|
+
const lookup = {
|
|
867
|
+
cwd: exec.agent === void 0 ? void 0 : await ctx.workingDirectory.ensure(exec.agent, exec.signal),
|
|
868
|
+
signal: exec.signal,
|
|
869
|
+
scope: exec.agent
|
|
870
|
+
};
|
|
871
|
+
const summary = (await ctx.skills.list(lookup)).find((skill) => skill.name === args.name);
|
|
872
|
+
if (!summary) throw new Error(`skill "${args.name}" is unknown or no longer available`);
|
|
873
|
+
if (!isModelInvocable(summary)) throw new Error(`skill "${args.name}" is not available for model invocation`);
|
|
874
|
+
const skill = await ctx.skills.get(args.name, lookup);
|
|
875
|
+
if (!skill) throw new Error(`skill "${args.name}" is unknown or no longer available`);
|
|
876
|
+
if (!isModelInvocable(skill)) throw new Error(`skill "${args.name}" is not available for model invocation`);
|
|
877
|
+
return {
|
|
878
|
+
name: skill.name,
|
|
879
|
+
provider: skill.provider,
|
|
880
|
+
...skill.resourceBase !== void 0 ? { resourceBase: { ...skill.resourceBase } } : {},
|
|
881
|
+
content: skill.content
|
|
882
|
+
};
|
|
883
|
+
},
|
|
884
|
+
presentCall(args) {
|
|
885
|
+
return {
|
|
886
|
+
card: "generic",
|
|
887
|
+
title: `Load skill ${args.name}`,
|
|
888
|
+
kind: "read",
|
|
889
|
+
rawInput: args.name
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
ctx.tools.register(skillTool);
|
|
894
|
+
ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
|
|
895
|
+
const decision = await next();
|
|
896
|
+
if (decision.kind === "reject") return decision;
|
|
897
|
+
const names = invokedSkillNames(messages);
|
|
898
|
+
if (names.length === 0) return decision;
|
|
899
|
+
signal.throwIfAborted();
|
|
900
|
+
const lookup = {
|
|
901
|
+
cwd: await ctx.workingDirectory.ensure(agent, signal),
|
|
902
|
+
signal,
|
|
903
|
+
scope: agent
|
|
904
|
+
};
|
|
905
|
+
const injections = [];
|
|
906
|
+
for (const name of names) {
|
|
907
|
+
const skill = await ctx.skills.get(name, lookup);
|
|
908
|
+
signal.throwIfAborted();
|
|
909
|
+
if (skill === void 0 || !isUserInvocable(skill)) continue;
|
|
910
|
+
const source = {
|
|
911
|
+
kind: "skill-invocation",
|
|
912
|
+
name,
|
|
913
|
+
form: "instructions"
|
|
914
|
+
};
|
|
915
|
+
injections.push(createUserMessage({
|
|
916
|
+
content: [{
|
|
917
|
+
type: "text",
|
|
918
|
+
text: renderSkillContent(skill)
|
|
919
|
+
}],
|
|
920
|
+
source
|
|
921
|
+
}));
|
|
922
|
+
}
|
|
923
|
+
if (injections.length === 0) return decision;
|
|
924
|
+
return {
|
|
925
|
+
...decision,
|
|
926
|
+
messages: [...decision.messages, ...injections]
|
|
927
|
+
};
|
|
928
|
+
});
|
|
929
|
+
ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
|
|
930
|
+
const decision = await next();
|
|
931
|
+
if (decision.kind === "reject") return decision;
|
|
932
|
+
signal.throwIfAborted();
|
|
933
|
+
const snapshot = ctx.tools.get(skillTool.name, agent) === skillTool ? await ctx.skills.snapshot({
|
|
934
|
+
cwd: await ctx.workingDirectory.ensure(agent, signal),
|
|
935
|
+
signal,
|
|
936
|
+
scope: agent
|
|
937
|
+
}) : {
|
|
938
|
+
skills: [],
|
|
939
|
+
complete: true
|
|
940
|
+
};
|
|
941
|
+
signal.throwIfAborted();
|
|
942
|
+
if (!snapshot.complete) return decision;
|
|
943
|
+
const skills = snapshot.skills.filter(isModelInvocable);
|
|
944
|
+
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength);
|
|
945
|
+
const digest = digestCatalogEntries(entries);
|
|
946
|
+
const history = catalogHistory(agent);
|
|
947
|
+
const existing = catalogMessage(decision.messages);
|
|
948
|
+
if (history.visibleDigest === digest) return existing === void 0 ? decision : {
|
|
949
|
+
...decision,
|
|
950
|
+
messages: decision.messages.filter((message) => message.id !== existing.message.id)
|
|
951
|
+
};
|
|
952
|
+
if (existing !== void 0 && digestCatalogEntries(existing.entries) === digest) return decision;
|
|
953
|
+
if (!history.published && skills.length === 0) return existing === void 0 ? decision : {
|
|
954
|
+
...decision,
|
|
955
|
+
messages: decision.messages.filter((message) => message.id !== existing.message.id)
|
|
956
|
+
};
|
|
957
|
+
const catalog = history.published ? renderCatalogUpdate(entries) : renderCatalogMessage(entries);
|
|
958
|
+
return {
|
|
959
|
+
...decision,
|
|
960
|
+
messages: existing === void 0 ? [...decision.messages, catalog] : decision.messages.map((message) => message.id === existing.message.id ? catalog : message)
|
|
961
|
+
};
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
function renderCatalogMessage(entries) {
|
|
965
|
+
return createUserMessage({
|
|
966
|
+
content: [{
|
|
967
|
+
type: "text",
|
|
968
|
+
text: [
|
|
969
|
+
"<system-reminder>",
|
|
970
|
+
"A skill is a reusable set of task-specific instructions. The following skills are available in this session:",
|
|
971
|
+
"",
|
|
972
|
+
"<available_skills>",
|
|
973
|
+
...renderCatalogEntries(entries),
|
|
974
|
+
"</available_skills>",
|
|
975
|
+
"",
|
|
976
|
+
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
|
977
|
+
"A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.",
|
|
978
|
+
"</system-reminder>"
|
|
979
|
+
].join("\n")
|
|
980
|
+
}],
|
|
981
|
+
source: {
|
|
982
|
+
kind: "skill-catalog",
|
|
983
|
+
form: "catalog",
|
|
984
|
+
entries
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
function renderCatalogUpdate(entries) {
|
|
989
|
+
const availability = entries.length === 0 ? ["No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.", "A user may still invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool for it."] : ["Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.", "A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill."];
|
|
990
|
+
return createUserMessage({
|
|
991
|
+
content: [{
|
|
992
|
+
type: "text",
|
|
993
|
+
text: [
|
|
994
|
+
"<system-reminder>",
|
|
995
|
+
"The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:",
|
|
996
|
+
"",
|
|
997
|
+
"<available_skills>",
|
|
998
|
+
...renderCatalogEntries(entries),
|
|
999
|
+
"</available_skills>",
|
|
1000
|
+
"",
|
|
1001
|
+
...availability,
|
|
1002
|
+
"</system-reminder>"
|
|
1003
|
+
].join("\n")
|
|
1004
|
+
}],
|
|
1005
|
+
source: {
|
|
1006
|
+
kind: "skill-catalog",
|
|
1007
|
+
form: "catalog",
|
|
1008
|
+
update: true,
|
|
1009
|
+
entries
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Model-facing catalog lines, projected from the same entries the source records.
|
|
1015
|
+
* The pseudo-XML escaping belongs to this frame, not to the published fact, so it
|
|
1016
|
+
* is applied here and never stored. Names are `isSkillName`-validated and carry
|
|
1017
|
+
* no escapable character.
|
|
1018
|
+
*/
|
|
1019
|
+
function renderCatalogEntries(entries) {
|
|
1020
|
+
return entries.map((entry) => `- \`${entry.name}\`: ${escapeText(entry.description)}`);
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Catalog identity over the durable entry list rather than the rendered prose.
|
|
1024
|
+
* The entries are what changes; the surrounding `<system-reminder>` framing is
|
|
1025
|
+
* written for the model and must not decide whether a republish is needed.
|
|
1026
|
+
*/
|
|
1027
|
+
function digestCatalogEntries(entries) {
|
|
1028
|
+
const canonical = entries.map((entry) => JSON.stringify([entry.name, entry.description])).join("\n");
|
|
1029
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Entries of one durable catalog message, or undefined when the record is not a
|
|
1033
|
+
* usable catalog.
|
|
1034
|
+
*
|
|
1035
|
+
* `agent.session.snapshotEvents()` may contain a resumed, forked, or externally written seed,
|
|
1036
|
+
* and seed validation only guarantees a source object with a non-empty `kind`;
|
|
1037
|
+
* no per-kind field is checked there. An unreadable record is therefore treated
|
|
1038
|
+
* as "not this plugin's catalog" — the posture the replaced content digest had —
|
|
1039
|
+
* rather than throwing inside the step listener, which would fail every
|
|
1040
|
+
* subsequent turn of that session.
|
|
1041
|
+
*/
|
|
1042
|
+
function readCatalogEntries(source) {
|
|
1043
|
+
const entries = source.entries;
|
|
1044
|
+
if (!Array.isArray(entries)) return void 0;
|
|
1045
|
+
const readable = [];
|
|
1046
|
+
for (const entry of entries) {
|
|
1047
|
+
if (typeof entry !== "object" || entry === null) return void 0;
|
|
1048
|
+
const { name, description } = entry;
|
|
1049
|
+
if (typeof name !== "string" || name === "" || typeof description !== "string") return void 0;
|
|
1050
|
+
readable.push({
|
|
1051
|
+
name,
|
|
1052
|
+
description
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
return readable;
|
|
1056
|
+
}
|
|
1057
|
+
function catalogHistory(agent) {
|
|
1058
|
+
const visible = new Set(agent.session.surface.nodes);
|
|
1059
|
+
let published = false;
|
|
1060
|
+
for (let index = agent.session.seq - 1; index >= 0; index -= 1) {
|
|
1061
|
+
const event = agent.session.eventAt(SessionSeq(index));
|
|
1062
|
+
if (event === void 0) throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`);
|
|
1063
|
+
if (event.type !== "user/message" || event.data.source.kind !== "skill-catalog") continue;
|
|
1064
|
+
const entries = readCatalogEntries(event.data.source);
|
|
1065
|
+
if (entries === void 0) continue;
|
|
1066
|
+
const digest = digestCatalogEntries(entries);
|
|
1067
|
+
published = true;
|
|
1068
|
+
if (visible.has(event.seq)) return {
|
|
1069
|
+
visibleDigest: digest,
|
|
1070
|
+
published
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
return { published };
|
|
1074
|
+
}
|
|
1075
|
+
function catalogMessage(messages) {
|
|
1076
|
+
for (const message of messages) {
|
|
1077
|
+
if (message.source.kind !== "skill-catalog") continue;
|
|
1078
|
+
const entries = readCatalogEntries(message.source);
|
|
1079
|
+
if (entries !== void 0) return {
|
|
1080
|
+
message,
|
|
1081
|
+
entries
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
function catalogDescription(value, maxLength) {
|
|
1086
|
+
const normalized = value.replaceAll(/\s+/g, " ").trim();
|
|
1087
|
+
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`;
|
|
1088
|
+
}
|
|
1089
|
+
function assertPositiveInteger(name, value, minimum = 1) {
|
|
1090
|
+
if (!Number.isInteger(value) || value < minimum) throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`);
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* A whitespace-bounded `/name` token (the public skill-name grammar) anywhere
|
|
1094
|
+
* in the text — the same word-boundary shape the transcript chip decoration
|
|
1095
|
+
* uses, so a gesture reads as one wherever it sits in the sentence. A second
|
|
1096
|
+
* `/` or any non-boundary character breaks the match, which keeps file paths
|
|
1097
|
+
* (`/usr/bin`) and fractions (`5/8`) out.
|
|
1098
|
+
*/
|
|
1099
|
+
const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g;
|
|
1100
|
+
/**
|
|
1101
|
+
* `/name` gesture tokens from the claimed user messages, deduplicated in
|
|
1102
|
+
* first-seen order. Every text block of direct user input is scanned; no
|
|
1103
|
+
* other source can forge a gesture.
|
|
1104
|
+
* @param messages - the step's claimed batch.
|
|
1105
|
+
* @returns candidate skill names, unvalidated against the registry.
|
|
1106
|
+
*/
|
|
1107
|
+
function invokedSkillNames(messages) {
|
|
1108
|
+
const names = [];
|
|
1109
|
+
for (const message of messages) {
|
|
1110
|
+
if (message.source.kind !== "user") continue;
|
|
1111
|
+
for (const block of message.content) {
|
|
1112
|
+
if (block.type !== "text") continue;
|
|
1113
|
+
for (const match of block.text.matchAll(SKILL_GESTURE)) {
|
|
1114
|
+
const name = match[2];
|
|
1115
|
+
if (name !== void 0 && !names.includes(name)) names.push(name);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return names;
|
|
1120
|
+
}
|
|
1121
|
+
//#endregion
|
|
1122
|
+
export { Config, apply, inject, name };
|