tinker-agent 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -1
- package/README.md +30 -7
- package/package.json +1 -1
- package/src/agent/loop.ts +22 -0
- package/src/agent/runtime-session.ts +158 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/tui-runner.tsx +16 -1
- package/src/events/stdout-event-printer.ts +14 -0
- package/src/events/types.ts +9 -0
- package/src/memory/contracts.ts +46 -0
- package/src/memory/memory-coordinator.ts +445 -4
- package/src/memory/memory-create-tool.ts +117 -0
- package/src/memory/memory-delete-tool.ts +88 -0
- package/src/memory/memory-store.ts +239 -0
- package/src/memory/memory-update-tool.ts +142 -0
- package/src/observation/observation-builder.ts +70 -0
- package/src/session/session-clone-helpers.ts +249 -0
- package/src/session/session-compatibility-codec.ts +401 -0
- package/src/session/session-store-contracts.ts +304 -0
- package/src/session/session-store-filesystem.ts +245 -0
- package/src/session/session-store-record-codecs.ts +1064 -0
- package/src/session/session-store-value-codecs.ts +156 -0
- package/src/session/session-store.ts +234 -3023
- package/src/session/session-tool-result-codec.ts +594 -0
- package/src/tools/ask-user.ts +100 -0
- package/src/tools/grep.ts +1 -3
- package/src/tools/registry.ts +30 -0
- package/src/tools/types.ts +71 -0
- package/src/tui/app.tsx +35 -5
- package/src/tui/components/ask-user.tsx +61 -0
- package/src/tui/components/footer.tsx +14 -1
- package/src/tui/components/prompt-input.tsx +4 -0
- package/src/tui/components/resume-session-picker.tsx +118 -37
- package/src/tui/event-store.ts +57 -0
- package/src/tui/tui-session-controller.ts +8 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parseMessageId, type MessageId } from "../ids/runtime-id";
|
|
3
|
+
import { sha256 } from "../model/model-request-preflight";
|
|
4
|
+
import type { ToolRawResult } from "../tools/types";
|
|
5
|
+
import { immutableCanonicalClone, immutableRecord } from "../context/protocol-frame";
|
|
6
|
+
import {
|
|
7
|
+
parseImageAssetId,
|
|
8
|
+
validateImageAssetRef,
|
|
9
|
+
validateOriginalImageName,
|
|
10
|
+
type ImageAssetRef,
|
|
11
|
+
} from "../image/image-types";
|
|
12
|
+
import {
|
|
13
|
+
SKILL_FILE_MAX_BYTES,
|
|
14
|
+
SKILL_RESOURCE_MAX_DEPTH,
|
|
15
|
+
SKILL_RESOURCE_MAX_ENTRIES,
|
|
16
|
+
} from "../skills/skill-loader";
|
|
17
|
+
import {
|
|
18
|
+
assertObjectKeys,
|
|
19
|
+
enumFromSql,
|
|
20
|
+
nonEmptyStringFromJson,
|
|
21
|
+
nonNegativeJsonInteger,
|
|
22
|
+
numberFromJson,
|
|
23
|
+
positiveJsonInteger,
|
|
24
|
+
recordFromSql,
|
|
25
|
+
sha256FromSql,
|
|
26
|
+
stringFromSql,
|
|
27
|
+
} from "./session-store-value-codecs";
|
|
28
|
+
|
|
29
|
+
export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
|
|
30
|
+
const raw = recordFromSql(value, "tool raw result");
|
|
31
|
+
const kind = enumFromSql(
|
|
32
|
+
raw.kind,
|
|
33
|
+
[
|
|
34
|
+
"read",
|
|
35
|
+
"view_image",
|
|
36
|
+
"write",
|
|
37
|
+
"edit",
|
|
38
|
+
"delete",
|
|
39
|
+
"glob",
|
|
40
|
+
"grep",
|
|
41
|
+
"bash",
|
|
42
|
+
"update_plan",
|
|
43
|
+
"task_list",
|
|
44
|
+
"task_output",
|
|
45
|
+
"task_input",
|
|
46
|
+
"task_stop",
|
|
47
|
+
"web_search",
|
|
48
|
+
"web_fetch",
|
|
49
|
+
"recall",
|
|
50
|
+
"context_maintenance",
|
|
51
|
+
"memory_search",
|
|
52
|
+
"memory_get",
|
|
53
|
+
"memory_create",
|
|
54
|
+
"memory_update",
|
|
55
|
+
"memory_delete",
|
|
56
|
+
"wait",
|
|
57
|
+
"ask_user",
|
|
58
|
+
"skill",
|
|
59
|
+
"mcp",
|
|
60
|
+
"generic",
|
|
61
|
+
] as const,
|
|
62
|
+
"tool raw result kind",
|
|
63
|
+
);
|
|
64
|
+
if (typeof raw.ok !== "boolean") {
|
|
65
|
+
throw new Error("tool raw result ok must be a boolean.");
|
|
66
|
+
}
|
|
67
|
+
if (kind === "skill") {
|
|
68
|
+
return decodeStoredSkillRawResult(raw);
|
|
69
|
+
}
|
|
70
|
+
if (kind === "view_image") {
|
|
71
|
+
return decodeStoredViewImageRawResult(raw);
|
|
72
|
+
}
|
|
73
|
+
if (kind === "context_maintenance") {
|
|
74
|
+
return decodeStoredContextMaintenanceRawResult(raw);
|
|
75
|
+
}
|
|
76
|
+
return immutableCanonicalClone(raw) as ToolRawResult;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decodeStoredContextMaintenanceRawResult(
|
|
80
|
+
raw: Record<string, unknown>,
|
|
81
|
+
): Extract<ToolRawResult, { kind: "context_maintenance" }> {
|
|
82
|
+
const operation = enumFromSql(
|
|
83
|
+
raw.operation,
|
|
84
|
+
["status", "candidates", "swap"] as const,
|
|
85
|
+
"context maintenance operation",
|
|
86
|
+
);
|
|
87
|
+
if (raw.ok === false) {
|
|
88
|
+
if (operation !== "swap") {
|
|
89
|
+
assertObjectKeys(
|
|
90
|
+
raw,
|
|
91
|
+
["kind", "ok", "operation", "error"],
|
|
92
|
+
["kind", "ok", "operation", "error"],
|
|
93
|
+
`failed context ${operation} result`,
|
|
94
|
+
);
|
|
95
|
+
return immutableRecord({
|
|
96
|
+
kind: "context_maintenance" as const,
|
|
97
|
+
ok: false as const,
|
|
98
|
+
operation,
|
|
99
|
+
error: nonEmptyStringFromJson(raw.error, `context ${operation} error`),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
assertObjectKeys(
|
|
103
|
+
raw,
|
|
104
|
+
["kind", "ok", "operation", "scheduled", "rejected", "error"],
|
|
105
|
+
["kind", "ok", "operation", "scheduled", "rejected"],
|
|
106
|
+
"failed context swap result",
|
|
107
|
+
);
|
|
108
|
+
if (!Array.isArray(raw.scheduled) || raw.scheduled.length !== 0) {
|
|
109
|
+
throw new Error("Failed context swap result must schedule no candidates.");
|
|
110
|
+
}
|
|
111
|
+
const rejected = decodeContextSwapRejected(raw.rejected);
|
|
112
|
+
const error =
|
|
113
|
+
raw.error === undefined
|
|
114
|
+
? undefined
|
|
115
|
+
: nonEmptyStringFromJson(raw.error, "context swap error");
|
|
116
|
+
if (rejected.length === 0 && error === undefined) {
|
|
117
|
+
throw new Error("Failed context swap result must explain its failure.");
|
|
118
|
+
}
|
|
119
|
+
return immutableRecord({
|
|
120
|
+
kind: "context_maintenance" as const,
|
|
121
|
+
ok: false as const,
|
|
122
|
+
operation,
|
|
123
|
+
scheduled: Object.freeze([]),
|
|
124
|
+
rejected,
|
|
125
|
+
...(error === undefined ? {} : { error }),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (raw.ok !== true) {
|
|
129
|
+
throw new Error("Context maintenance raw result ok must be a boolean.");
|
|
130
|
+
}
|
|
131
|
+
if (operation === "status") {
|
|
132
|
+
assertObjectKeys(
|
|
133
|
+
raw,
|
|
134
|
+
[
|
|
135
|
+
"kind",
|
|
136
|
+
"ok",
|
|
137
|
+
"operation",
|
|
138
|
+
"usedInputTokens",
|
|
139
|
+
"inputBudgetTokens",
|
|
140
|
+
"pressure",
|
|
141
|
+
"triggerTokens",
|
|
142
|
+
"source",
|
|
143
|
+
],
|
|
144
|
+
[
|
|
145
|
+
"kind",
|
|
146
|
+
"ok",
|
|
147
|
+
"operation",
|
|
148
|
+
"usedInputTokens",
|
|
149
|
+
"inputBudgetTokens",
|
|
150
|
+
"pressure",
|
|
151
|
+
"triggerTokens",
|
|
152
|
+
"source",
|
|
153
|
+
],
|
|
154
|
+
"context status result",
|
|
155
|
+
);
|
|
156
|
+
return immutableRecord({
|
|
157
|
+
kind: "context_maintenance" as const,
|
|
158
|
+
ok: true as const,
|
|
159
|
+
operation,
|
|
160
|
+
usedInputTokens: nonNegativeJsonInteger(
|
|
161
|
+
raw.usedInputTokens,
|
|
162
|
+
"context status usedInputTokens",
|
|
163
|
+
),
|
|
164
|
+
inputBudgetTokens: positiveJsonInteger(
|
|
165
|
+
raw.inputBudgetTokens,
|
|
166
|
+
"context status inputBudgetTokens",
|
|
167
|
+
),
|
|
168
|
+
pressure: enumFromSql(
|
|
169
|
+
raw.pressure,
|
|
170
|
+
["normal", "high", "critical"] as const,
|
|
171
|
+
"context status pressure",
|
|
172
|
+
),
|
|
173
|
+
triggerTokens: positiveJsonInteger(
|
|
174
|
+
raw.triggerTokens,
|
|
175
|
+
"context status triggerTokens",
|
|
176
|
+
),
|
|
177
|
+
source: enumFromSql(
|
|
178
|
+
raw.source,
|
|
179
|
+
[
|
|
180
|
+
"estimated_full",
|
|
181
|
+
"provider_measured",
|
|
182
|
+
"measured_plus_estimated_delta",
|
|
183
|
+
] as const,
|
|
184
|
+
"context status source",
|
|
185
|
+
),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (operation === "candidates") {
|
|
189
|
+
assertObjectKeys(
|
|
190
|
+
raw,
|
|
191
|
+
["kind", "ok", "operation", "total", "candidates"],
|
|
192
|
+
["kind", "ok", "operation", "total", "candidates"],
|
|
193
|
+
"context swap candidates result",
|
|
194
|
+
);
|
|
195
|
+
if (!Array.isArray(raw.candidates) || raw.candidates.length > 50) {
|
|
196
|
+
throw new Error("Context swap candidates result has an invalid page.");
|
|
197
|
+
}
|
|
198
|
+
const candidates = raw.candidates.map((value, index) => {
|
|
199
|
+
const candidate = recordFromSql(value, `context candidate ${index}`);
|
|
200
|
+
assertObjectKeys(
|
|
201
|
+
candidate,
|
|
202
|
+
["candidateId", "label", "ordinal", "savingsBytes"],
|
|
203
|
+
["candidateId", "label", "ordinal", "savingsBytes"],
|
|
204
|
+
`context candidate ${index}`,
|
|
205
|
+
);
|
|
206
|
+
const label = stringFromSql(candidate.label, `context candidate ${index} label`);
|
|
207
|
+
if (
|
|
208
|
+
label === "" ||
|
|
209
|
+
label !== label.replace(/[\p{Cc}\p{Cf}\s]+/gu, " ").trim() ||
|
|
210
|
+
Buffer.byteLength(label, "utf8") > 80
|
|
211
|
+
) {
|
|
212
|
+
throw new Error(`Context candidate ${index} label is invalid or too large.`);
|
|
213
|
+
}
|
|
214
|
+
return immutableRecord({
|
|
215
|
+
candidateId: parseMessageId(
|
|
216
|
+
stringFromSql(candidate.candidateId, `context candidate ${index} ID`),
|
|
217
|
+
),
|
|
218
|
+
label,
|
|
219
|
+
ordinal: positiveJsonInteger(
|
|
220
|
+
candidate.ordinal,
|
|
221
|
+
`context candidate ${index} ordinal`,
|
|
222
|
+
),
|
|
223
|
+
savingsBytes: positiveJsonInteger(
|
|
224
|
+
candidate.savingsBytes,
|
|
225
|
+
`context candidate ${index} savingsBytes`,
|
|
226
|
+
),
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
if (
|
|
230
|
+
new Set(candidates.map((candidate) => candidate.candidateId)).size !==
|
|
231
|
+
candidates.length ||
|
|
232
|
+
candidates.some(
|
|
233
|
+
(candidate, index) =>
|
|
234
|
+
index > 0 &&
|
|
235
|
+
candidate.ordinal <= (candidates[index - 1]?.ordinal ?? candidate.ordinal),
|
|
236
|
+
)
|
|
237
|
+
) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
"Context swap candidates must have unique IDs and ascending ordinals.",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const total = nonNegativeJsonInteger(raw.total, "context candidates total");
|
|
243
|
+
if (total < candidates.length) {
|
|
244
|
+
throw new Error("Context candidates total is smaller than its page.");
|
|
245
|
+
}
|
|
246
|
+
return immutableRecord({
|
|
247
|
+
kind: "context_maintenance" as const,
|
|
248
|
+
ok: true as const,
|
|
249
|
+
operation,
|
|
250
|
+
total,
|
|
251
|
+
candidates: Object.freeze(candidates),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
assertObjectKeys(
|
|
256
|
+
raw,
|
|
257
|
+
["kind", "ok", "operation", "scheduled", "rejected", "note"],
|
|
258
|
+
["kind", "ok", "operation", "scheduled", "rejected", "note"],
|
|
259
|
+
"context swap result",
|
|
260
|
+
);
|
|
261
|
+
if (!Array.isArray(raw.scheduled) || raw.scheduled.length < 1) {
|
|
262
|
+
throw new Error("Successful context swap result must schedule candidates.");
|
|
263
|
+
}
|
|
264
|
+
const scheduled = raw.scheduled.map((value, index) => {
|
|
265
|
+
const candidate = recordFromSql(value, `scheduled context candidate ${index}`);
|
|
266
|
+
assertObjectKeys(
|
|
267
|
+
candidate,
|
|
268
|
+
["candidateId", "savingsBytes"],
|
|
269
|
+
["candidateId", "savingsBytes"],
|
|
270
|
+
`scheduled context candidate ${index}`,
|
|
271
|
+
);
|
|
272
|
+
return immutableRecord({
|
|
273
|
+
candidateId: parseMessageId(
|
|
274
|
+
stringFromSql(candidate.candidateId, `scheduled candidate ${index} ID`),
|
|
275
|
+
),
|
|
276
|
+
savingsBytes: positiveJsonInteger(
|
|
277
|
+
candidate.savingsBytes,
|
|
278
|
+
`scheduled candidate ${index} savingsBytes`,
|
|
279
|
+
),
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
if (
|
|
283
|
+
scheduled.length > 16 ||
|
|
284
|
+
new Set(scheduled.map((candidate) => candidate.candidateId)).size !==
|
|
285
|
+
scheduled.length
|
|
286
|
+
) {
|
|
287
|
+
throw new Error("Successful context swap result has invalid scheduled IDs.");
|
|
288
|
+
}
|
|
289
|
+
const rejected = decodeContextSwapRejected(raw.rejected);
|
|
290
|
+
if (
|
|
291
|
+
scheduled.length + rejected.length > 16 ||
|
|
292
|
+
scheduled.some((scheduledCandidate) =>
|
|
293
|
+
rejected.some(
|
|
294
|
+
(rejectedCandidate) =>
|
|
295
|
+
rejectedCandidate.candidateId === scheduledCandidate.candidateId,
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
) {
|
|
299
|
+
throw new Error("Context swap result candidate partitions are invalid.");
|
|
300
|
+
}
|
|
301
|
+
const note = stringFromSql(raw.note, "context swap note");
|
|
302
|
+
return immutableRecord({
|
|
303
|
+
kind: "context_maintenance" as const,
|
|
304
|
+
ok: true as const,
|
|
305
|
+
operation,
|
|
306
|
+
scheduled: Object.freeze(scheduled),
|
|
307
|
+
rejected,
|
|
308
|
+
note,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function decodeContextSwapRejected(value: unknown): readonly {
|
|
313
|
+
readonly candidateId: MessageId;
|
|
314
|
+
readonly reason: string;
|
|
315
|
+
}[] {
|
|
316
|
+
if (!Array.isArray(value) || value.length > 16) {
|
|
317
|
+
throw new Error("Context swap rejected candidates must be an array of at most 16.");
|
|
318
|
+
}
|
|
319
|
+
const rejected = value.map((entry, index) => {
|
|
320
|
+
const candidate = recordFromSql(entry, `rejected context candidate ${index}`);
|
|
321
|
+
assertObjectKeys(
|
|
322
|
+
candidate,
|
|
323
|
+
["candidateId", "reason"],
|
|
324
|
+
["candidateId", "reason"],
|
|
325
|
+
`rejected context candidate ${index}`,
|
|
326
|
+
);
|
|
327
|
+
const reason = stringFromSql(
|
|
328
|
+
candidate.reason,
|
|
329
|
+
`rejected context candidate ${index} reason`,
|
|
330
|
+
);
|
|
331
|
+
if (!/^[a-z][a-z0-9_]{0,79}$/.test(reason)) {
|
|
332
|
+
throw new Error(`Rejected context candidate ${index} reason is invalid.`);
|
|
333
|
+
}
|
|
334
|
+
return immutableRecord({
|
|
335
|
+
candidateId: parseMessageId(
|
|
336
|
+
stringFromSql(candidate.candidateId, `rejected candidate ${index} ID`),
|
|
337
|
+
),
|
|
338
|
+
reason,
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
if (
|
|
342
|
+
new Set(rejected.map((candidate) => candidate.candidateId)).size !== rejected.length
|
|
343
|
+
) {
|
|
344
|
+
throw new Error("Context swap rejected candidate IDs must be unique.");
|
|
345
|
+
}
|
|
346
|
+
return Object.freeze(rejected);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function decodeStoredViewImageRawResult(
|
|
350
|
+
raw: Record<string, unknown>,
|
|
351
|
+
): Extract<ToolRawResult, { kind: "view_image" }> {
|
|
352
|
+
assertObjectKeys(
|
|
353
|
+
raw,
|
|
354
|
+
["kind", "ok", "filePath", "originalName", "asset", "error"],
|
|
355
|
+
["kind", "ok", "filePath"],
|
|
356
|
+
"ViewImage raw result",
|
|
357
|
+
);
|
|
358
|
+
const filePath = stringFromSql(raw.filePath, "ViewImage filePath");
|
|
359
|
+
if (raw.ok === false) {
|
|
360
|
+
if (raw.originalName !== undefined || raw.asset !== undefined) {
|
|
361
|
+
throw new Error("Failed ViewImage raw result cannot contain image metadata.");
|
|
362
|
+
}
|
|
363
|
+
const error = stringFromSql(raw.error, "ViewImage error");
|
|
364
|
+
if (error.trim() === "") {
|
|
365
|
+
throw new Error("Failed ViewImage raw result error must not be empty.");
|
|
366
|
+
}
|
|
367
|
+
return immutableRecord({ kind: "view_image", ok: false, filePath, error });
|
|
368
|
+
}
|
|
369
|
+
if (raw.ok !== true || raw.error !== undefined || filePath.trim() === "") {
|
|
370
|
+
throw new Error("Successful ViewImage raw result is invalid.");
|
|
371
|
+
}
|
|
372
|
+
const originalName = stringFromSql(raw.originalName, "ViewImage originalName");
|
|
373
|
+
validateOriginalImageName(originalName);
|
|
374
|
+
const storedAsset = recordFromSql(raw.asset, "ViewImage asset");
|
|
375
|
+
assertObjectKeys(
|
|
376
|
+
storedAsset,
|
|
377
|
+
["assetId", "mimeType", "byteLength", "width", "height"],
|
|
378
|
+
["assetId", "mimeType", "byteLength", "width", "height"],
|
|
379
|
+
"ViewImage asset",
|
|
380
|
+
);
|
|
381
|
+
const asset: ImageAssetRef = immutableRecord({
|
|
382
|
+
assetId: parseImageAssetId(stringFromSql(storedAsset.assetId, "assetId")),
|
|
383
|
+
mimeType: enumFromSql(
|
|
384
|
+
storedAsset.mimeType,
|
|
385
|
+
["image/png", "image/jpeg", "image/webp"] as const,
|
|
386
|
+
"ViewImage asset mimeType",
|
|
387
|
+
),
|
|
388
|
+
byteLength: numberFromJson(storedAsset.byteLength, "ViewImage asset byteLength"),
|
|
389
|
+
width: numberFromJson(storedAsset.width, "ViewImage asset width"),
|
|
390
|
+
height: numberFromJson(storedAsset.height, "ViewImage asset height"),
|
|
391
|
+
});
|
|
392
|
+
validateImageAssetRef(asset);
|
|
393
|
+
return immutableRecord({
|
|
394
|
+
kind: "view_image",
|
|
395
|
+
ok: true,
|
|
396
|
+
filePath,
|
|
397
|
+
originalName,
|
|
398
|
+
asset,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function decodeStoredSkillRawResult(
|
|
403
|
+
raw: Record<string, unknown>,
|
|
404
|
+
): Extract<ToolRawResult, { kind: "skill" }> {
|
|
405
|
+
const status = enumFromSql(
|
|
406
|
+
raw.status,
|
|
407
|
+
["loaded", "already_loaded", "already_active", "failed"] as const,
|
|
408
|
+
"Skill raw result status",
|
|
409
|
+
);
|
|
410
|
+
const name = stringFromSql(raw.name, "Skill raw result name");
|
|
411
|
+
if (status === "failed") {
|
|
412
|
+
if (raw.ok !== false) {
|
|
413
|
+
throw new Error("Failed Skill raw result must have ok=false.");
|
|
414
|
+
}
|
|
415
|
+
assertObjectKeys(
|
|
416
|
+
raw,
|
|
417
|
+
["kind", "ok", "status", "name", "errorCode", "error"],
|
|
418
|
+
["kind", "ok", "status", "name", "errorCode", "error"],
|
|
419
|
+
"failed Skill raw result",
|
|
420
|
+
);
|
|
421
|
+
const errorCode = stringFromSql(raw.errorCode, "Skill errorCode");
|
|
422
|
+
const error = stringFromSql(raw.error, "Skill error");
|
|
423
|
+
if (
|
|
424
|
+
(name !== "" && !isValidSkillName(name)) ||
|
|
425
|
+
!/^[A-Z][A-Z0-9_]{0,79}$/.test(errorCode) ||
|
|
426
|
+
error.trim() === ""
|
|
427
|
+
) {
|
|
428
|
+
throw new Error("Failed Skill raw result fields are invalid.");
|
|
429
|
+
}
|
|
430
|
+
return immutableRecord({
|
|
431
|
+
kind: "skill" as const,
|
|
432
|
+
ok: false as const,
|
|
433
|
+
status,
|
|
434
|
+
name,
|
|
435
|
+
errorCode,
|
|
436
|
+
error,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (raw.ok !== true) {
|
|
440
|
+
throw new Error("Successful Skill raw result must have ok=true.");
|
|
441
|
+
}
|
|
442
|
+
const scope = enumFromSql(
|
|
443
|
+
raw.scope,
|
|
444
|
+
["project", "user"] as const,
|
|
445
|
+
"Skill raw result scope",
|
|
446
|
+
);
|
|
447
|
+
const skillFileSha256 = sha256FromSql(raw.sha256, "Skill raw result sha256");
|
|
448
|
+
if (!isValidSkillName(name)) {
|
|
449
|
+
throw new Error("Successful Skill raw result name is invalid.");
|
|
450
|
+
}
|
|
451
|
+
if (status === "already_loaded") {
|
|
452
|
+
assertObjectKeys(
|
|
453
|
+
raw,
|
|
454
|
+
["kind", "ok", "status", "name", "scope", "lifecycle", "sha256"],
|
|
455
|
+
["kind", "ok", "status", "name", "scope", "lifecycle", "sha256"],
|
|
456
|
+
"already loaded Skill raw result",
|
|
457
|
+
);
|
|
458
|
+
return immutableRecord({
|
|
459
|
+
kind: "skill" as const,
|
|
460
|
+
ok: true as const,
|
|
461
|
+
status,
|
|
462
|
+
name,
|
|
463
|
+
scope,
|
|
464
|
+
lifecycle: enumFromSql(
|
|
465
|
+
raw.lifecycle,
|
|
466
|
+
["pending", "dispatched"] as const,
|
|
467
|
+
"Skill lifecycle",
|
|
468
|
+
),
|
|
469
|
+
sha256: skillFileSha256,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (status === "already_active") {
|
|
473
|
+
assertObjectKeys(
|
|
474
|
+
raw,
|
|
475
|
+
["kind", "ok", "status", "name", "scope", "sha256"],
|
|
476
|
+
["kind", "ok", "status", "name", "scope", "sha256"],
|
|
477
|
+
"already active Skill raw result",
|
|
478
|
+
);
|
|
479
|
+
return immutableRecord({
|
|
480
|
+
kind: "skill" as const,
|
|
481
|
+
ok: true as const,
|
|
482
|
+
status,
|
|
483
|
+
name,
|
|
484
|
+
scope,
|
|
485
|
+
sha256: skillFileSha256,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
assertObjectKeys(
|
|
489
|
+
raw,
|
|
490
|
+
[
|
|
491
|
+
"kind",
|
|
492
|
+
"ok",
|
|
493
|
+
"status",
|
|
494
|
+
"name",
|
|
495
|
+
"scope",
|
|
496
|
+
"directory",
|
|
497
|
+
"skillFilePath",
|
|
498
|
+
"content",
|
|
499
|
+
"byteLength",
|
|
500
|
+
"sha256",
|
|
501
|
+
"resources",
|
|
502
|
+
"resourcesTruncated",
|
|
503
|
+
],
|
|
504
|
+
[
|
|
505
|
+
"kind",
|
|
506
|
+
"ok",
|
|
507
|
+
"status",
|
|
508
|
+
"name",
|
|
509
|
+
"scope",
|
|
510
|
+
"directory",
|
|
511
|
+
"skillFilePath",
|
|
512
|
+
"content",
|
|
513
|
+
"byteLength",
|
|
514
|
+
"sha256",
|
|
515
|
+
"resources",
|
|
516
|
+
"resourcesTruncated",
|
|
517
|
+
],
|
|
518
|
+
"loaded Skill raw result",
|
|
519
|
+
);
|
|
520
|
+
if (
|
|
521
|
+
!Array.isArray(raw.resources) ||
|
|
522
|
+
raw.resources.some((entry) => typeof entry !== "string") ||
|
|
523
|
+
raw.resources.length > SKILL_RESOURCE_MAX_ENTRIES ||
|
|
524
|
+
typeof raw.resourcesTruncated !== "boolean"
|
|
525
|
+
) {
|
|
526
|
+
throw new Error("Loaded Skill resource manifest is invalid.");
|
|
527
|
+
}
|
|
528
|
+
const directory = stringFromSql(raw.directory, "Skill directory");
|
|
529
|
+
const skillFilePath = stringFromSql(raw.skillFilePath, "Skill file path");
|
|
530
|
+
const content = stringFromSql(raw.content, "Skill content");
|
|
531
|
+
const byteLength = numberFromJson(raw.byteLength, "Skill byteLength");
|
|
532
|
+
const resources = raw.resources as string[];
|
|
533
|
+
if (
|
|
534
|
+
!path.isAbsolute(directory) ||
|
|
535
|
+
!path.isAbsolute(skillFilePath) ||
|
|
536
|
+
!isPathWithin(directory, skillFilePath) ||
|
|
537
|
+
byteLength < 1 ||
|
|
538
|
+
byteLength > SKILL_FILE_MAX_BYTES ||
|
|
539
|
+
Buffer.byteLength(content, "utf8") !== byteLength ||
|
|
540
|
+
sha256(content) !== skillFileSha256 ||
|
|
541
|
+
!isValidResourceManifest(resources)
|
|
542
|
+
) {
|
|
543
|
+
throw new Error("Loaded Skill raw result snapshot is invalid.");
|
|
544
|
+
}
|
|
545
|
+
return immutableRecord({
|
|
546
|
+
kind: "skill" as const,
|
|
547
|
+
ok: true as const,
|
|
548
|
+
status,
|
|
549
|
+
name,
|
|
550
|
+
scope,
|
|
551
|
+
directory,
|
|
552
|
+
skillFilePath,
|
|
553
|
+
content,
|
|
554
|
+
byteLength,
|
|
555
|
+
sha256: skillFileSha256,
|
|
556
|
+
resources: Object.freeze([...resources]),
|
|
557
|
+
resourcesTruncated: raw.resourcesTruncated,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isValidSkillName(value: string): boolean {
|
|
562
|
+
return value.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isPathWithin(root: string, candidate: string): boolean {
|
|
566
|
+
const relative = path.relative(root, candidate);
|
|
567
|
+
return (
|
|
568
|
+
relative !== "" &&
|
|
569
|
+
relative !== ".." &&
|
|
570
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
571
|
+
!path.isAbsolute(relative)
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isValidResourceManifest(resources: readonly string[]): boolean {
|
|
576
|
+
let previous: string | undefined;
|
|
577
|
+
for (const resource of resources) {
|
|
578
|
+
const parts = resource.split("/");
|
|
579
|
+
if (
|
|
580
|
+
resource === "" ||
|
|
581
|
+
resource.includes("\\") ||
|
|
582
|
+
path.posix.isAbsolute(resource) ||
|
|
583
|
+
path.posix.normalize(resource) !== resource ||
|
|
584
|
+
!["assets", "references", "scripts"].includes(parts[0] ?? "") ||
|
|
585
|
+
parts.length < 2 ||
|
|
586
|
+
parts.length > SKILL_RESOURCE_MAX_DEPTH + 1 ||
|
|
587
|
+
(previous !== undefined && previous >= resource)
|
|
588
|
+
) {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
previous = resource;
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
2
|
+
import {
|
|
3
|
+
defineToolExecutor,
|
|
4
|
+
type AskUserRawResult,
|
|
5
|
+
type AskUserRequest,
|
|
6
|
+
type ToolExecutionContext,
|
|
7
|
+
type ToolExecutor,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
export function createAskUserToolExecutor(): ToolExecutor {
|
|
11
|
+
return defineToolExecutor("ask_user", {
|
|
12
|
+
definition: {
|
|
13
|
+
name: "AskUser",
|
|
14
|
+
description:
|
|
15
|
+
"Ask the user one multiple-choice question when a material ambiguity prevents correct progress. Investigate the conversation and workspace first. Provide 2-6 options, each as a complete answer the user can select. The user may select one option or dismiss the question. If dismissed, use your own judgment and do not immediately repeat the same question. Call AskUser alone, without other tool calls in the same response.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
question: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "The question shown to the user.",
|
|
22
|
+
},
|
|
23
|
+
options: {
|
|
24
|
+
type: "array",
|
|
25
|
+
minItems: 2,
|
|
26
|
+
maxItems: 6,
|
|
27
|
+
items: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
description: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "An answer the user can select.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ["description"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ["question", "options"],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async execute(
|
|
43
|
+
args,
|
|
44
|
+
_call,
|
|
45
|
+
context: ToolExecutionContext,
|
|
46
|
+
): Promise<AskUserRawResult> {
|
|
47
|
+
throwIfTurnCancelled(context.signal);
|
|
48
|
+
const parsed = parseAskUserArgs(args);
|
|
49
|
+
if (!parsed.ok) {
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
if (context.askUser === undefined) {
|
|
53
|
+
return { ok: false, error: "AskUser interaction is unavailable." };
|
|
54
|
+
}
|
|
55
|
+
const response = await context.askUser(parsed.request);
|
|
56
|
+
throwIfTurnCancelled(context.signal);
|
|
57
|
+
return response.outcome === "selected"
|
|
58
|
+
? { ok: true, outcome: "selected", answer: response.answer }
|
|
59
|
+
: { ok: true, outcome: "dismissed" };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseAskUserArgs(
|
|
65
|
+
args: unknown,
|
|
66
|
+
): { ok: true; request: AskUserRequest } | { ok: false; error: string } {
|
|
67
|
+
if (!isRecord(args)) {
|
|
68
|
+
return { ok: false, error: "AskUser arguments must be an object." };
|
|
69
|
+
}
|
|
70
|
+
if (typeof args.question !== "string") {
|
|
71
|
+
return { ok: false, error: "AskUser question must be a string." };
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(args.options)) {
|
|
74
|
+
return { ok: false, error: "AskUser options must be an array." };
|
|
75
|
+
}
|
|
76
|
+
if (args.options.length < 2 || args.options.length > 6) {
|
|
77
|
+
return { ok: false, error: "AskUser options must contain between 2 and 6 items." };
|
|
78
|
+
}
|
|
79
|
+
const options: { description: string }[] = [];
|
|
80
|
+
for (const [index, option] of args.options.entries()) {
|
|
81
|
+
if (!isRecord(option) || typeof option.description !== "string") {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `AskUser option ${index + 1} must be an object with a string description.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
options.push({ description: option.description });
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
request: Object.freeze({
|
|
92
|
+
question: args.question,
|
|
93
|
+
options: Object.freeze(options.map((option) => Object.freeze(option))),
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
package/src/tools/grep.ts
CHANGED
|
@@ -315,9 +315,7 @@ export function buildRipgrepArgs(
|
|
|
315
315
|
}
|
|
316
316
|
|
|
317
317
|
if (input.glob !== undefined) {
|
|
318
|
-
|
|
319
|
-
args.push("--glob", pattern);
|
|
320
|
-
}
|
|
318
|
+
args.push("--glob", input.glob);
|
|
321
319
|
}
|
|
322
320
|
|
|
323
321
|
args.push("-e", input.pattern, absoluteSearchPath);
|