@ct-agents/prompts 0.0.1
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/package.json +20 -0
- package/src/index.ts +532 -0
- package/src/load-skill.ts +287 -0
- package/src/markdown-frontmatter.ts +78 -0
- package/src/postgres-skill-store.ts +158 -0
- package/src/testing.ts +183 -0
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ct-agents/prompts",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src"
|
|
7
|
+
],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./testing.js": "./src/testing.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"zod": "4.4.3",
|
|
14
|
+
"@ct-agents/protocol": "0.0.1",
|
|
15
|
+
"@ct-agents/store": "0.0.1"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { SqlPool } from '@ct-agents/store';
|
|
3
|
+
import type {
|
|
4
|
+
AssemblePromptInput,
|
|
5
|
+
CreatePromptFragmentInput,
|
|
6
|
+
GeneratorRegistry,
|
|
7
|
+
PromptFragmentStore,
|
|
8
|
+
PromptFragmentView,
|
|
9
|
+
PromptGenerator,
|
|
10
|
+
PromptRegistry,
|
|
11
|
+
ResourceSlots,
|
|
12
|
+
UpdatePromptFragmentInput,
|
|
13
|
+
} from '@ct-agents/protocol';
|
|
14
|
+
|
|
15
|
+
type PromptFragmentRow = {
|
|
16
|
+
app_id?: string | null;
|
|
17
|
+
id: string;
|
|
18
|
+
version: number;
|
|
19
|
+
name: string;
|
|
20
|
+
description?: string | null;
|
|
21
|
+
content: string;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
created_at?: string | Date | null;
|
|
24
|
+
updated_at?: string | Date | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function assertFragmentContent(name: string, content: string) {
|
|
28
|
+
if (!name.trim()) {
|
|
29
|
+
throw new Error('name 为必填');
|
|
30
|
+
}
|
|
31
|
+
if (!content.trim()) {
|
|
32
|
+
throw new Error('content 为必填');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toIsoString(value: string | Date | null | undefined) {
|
|
37
|
+
if (!value) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mapPromptFragmentRow(row: PromptFragmentRow): PromptFragmentView {
|
|
45
|
+
return {
|
|
46
|
+
appId: row.app_id ?? null,
|
|
47
|
+
id: row.id,
|
|
48
|
+
version: row.version,
|
|
49
|
+
name: row.name,
|
|
50
|
+
...(row.description?.trim() ? { description: row.description.trim() } : {}),
|
|
51
|
+
content: row.content,
|
|
52
|
+
enabled: row.enabled,
|
|
53
|
+
source: {
|
|
54
|
+
kind: 'persisted',
|
|
55
|
+
},
|
|
56
|
+
createdAt: toIsoString(row.created_at),
|
|
57
|
+
updatedAt: toIsoString(row.updated_at),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type PostgresPromptFragmentStoreOptions = {
|
|
62
|
+
createId?: () => string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export class PostgresPromptFragmentStore implements PromptFragmentStore {
|
|
66
|
+
private readonly createId: () => string;
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
private readonly pool: SqlPool,
|
|
70
|
+
options: PostgresPromptFragmentStoreOptions = {},
|
|
71
|
+
) {
|
|
72
|
+
this.createId = options.createId ?? (() => `persisted:${randomUUID()}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async list(query?: { appId?: string; includeGlobal?: boolean }) {
|
|
76
|
+
const client = await this.pool.connect();
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const conditions = ['enabled = true'];
|
|
80
|
+
const params: unknown[] = [];
|
|
81
|
+
if (query?.appId) {
|
|
82
|
+
params.push(query.appId);
|
|
83
|
+
conditions.push(query.includeGlobal ? '(app_id = $1 or app_id is null)' : 'app_id = $1');
|
|
84
|
+
}
|
|
85
|
+
const result = await client.query<PromptFragmentRow>(
|
|
86
|
+
`
|
|
87
|
+
select
|
|
88
|
+
app_id,
|
|
89
|
+
id,
|
|
90
|
+
version,
|
|
91
|
+
name,
|
|
92
|
+
description,
|
|
93
|
+
content,
|
|
94
|
+
enabled,
|
|
95
|
+
created_at,
|
|
96
|
+
updated_at
|
|
97
|
+
from agent.agent_prompt_fragments
|
|
98
|
+
where ${conditions.join(' and ')}
|
|
99
|
+
order by updated_at desc, id desc
|
|
100
|
+
`,
|
|
101
|
+
params,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return result.rows.map(mapPromptFragmentRow);
|
|
105
|
+
} finally {
|
|
106
|
+
client.release();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async get(id: string) {
|
|
111
|
+
const client = await this.pool.connect();
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const result = await client.query<PromptFragmentRow>(
|
|
115
|
+
`
|
|
116
|
+
select
|
|
117
|
+
app_id,
|
|
118
|
+
id,
|
|
119
|
+
version,
|
|
120
|
+
name,
|
|
121
|
+
description,
|
|
122
|
+
content,
|
|
123
|
+
enabled,
|
|
124
|
+
created_at,
|
|
125
|
+
updated_at
|
|
126
|
+
from agent.agent_prompt_fragments
|
|
127
|
+
where id = $1
|
|
128
|
+
limit 1
|
|
129
|
+
`,
|
|
130
|
+
[id],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return result.rows[0] ? mapPromptFragmentRow(result.rows[0]) : null;
|
|
134
|
+
} finally {
|
|
135
|
+
client.release();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async create(input: CreatePromptFragmentInput) {
|
|
140
|
+
assertFragmentContent(input.name, input.content);
|
|
141
|
+
const client = await this.pool.connect();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const result = await client.query<PromptFragmentRow>(
|
|
145
|
+
`
|
|
146
|
+
insert into agent.agent_prompt_fragments (
|
|
147
|
+
app_id,
|
|
148
|
+
id,
|
|
149
|
+
name,
|
|
150
|
+
description,
|
|
151
|
+
content,
|
|
152
|
+
enabled
|
|
153
|
+
)
|
|
154
|
+
values ($1, $2, $3, $4, $5, $6)
|
|
155
|
+
returning
|
|
156
|
+
app_id,
|
|
157
|
+
id,
|
|
158
|
+
version,
|
|
159
|
+
name,
|
|
160
|
+
description,
|
|
161
|
+
content,
|
|
162
|
+
enabled,
|
|
163
|
+
created_at,
|
|
164
|
+
updated_at
|
|
165
|
+
`,
|
|
166
|
+
[
|
|
167
|
+
input.appId ?? null,
|
|
168
|
+
this.createId(),
|
|
169
|
+
input.name.trim(),
|
|
170
|
+
input.description?.trim() || null,
|
|
171
|
+
input.content.trim(),
|
|
172
|
+
input.enabled ?? true,
|
|
173
|
+
],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return mapPromptFragmentRow(result.rows[0]);
|
|
177
|
+
} finally {
|
|
178
|
+
client.release();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async update(input: UpdatePromptFragmentInput) {
|
|
183
|
+
const current = await this.get(input.id);
|
|
184
|
+
if (!current) {
|
|
185
|
+
throw new Error(`Prompt fragment not found: ${input.id}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const name = input.name === undefined ? current.name : input.name.trim();
|
|
189
|
+
const description = input.description === undefined
|
|
190
|
+
? current.description ?? null
|
|
191
|
+
: (input.description.trim() || null);
|
|
192
|
+
const content = input.content === undefined ? current.content : input.content.trim();
|
|
193
|
+
assertFragmentContent(name, content);
|
|
194
|
+
const client = await this.pool.connect();
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const result = await client.query<PromptFragmentRow>(
|
|
198
|
+
`
|
|
199
|
+
update agent.agent_prompt_fragments
|
|
200
|
+
set
|
|
201
|
+
name = $2,
|
|
202
|
+
description = $3,
|
|
203
|
+
content = $4,
|
|
204
|
+
enabled = $5,
|
|
205
|
+
version = version + 1,
|
|
206
|
+
updated_at = now()
|
|
207
|
+
where id = $1 and version = $6
|
|
208
|
+
returning
|
|
209
|
+
app_id,
|
|
210
|
+
id,
|
|
211
|
+
version,
|
|
212
|
+
name,
|
|
213
|
+
description,
|
|
214
|
+
content,
|
|
215
|
+
enabled,
|
|
216
|
+
created_at,
|
|
217
|
+
updated_at
|
|
218
|
+
`,
|
|
219
|
+
[
|
|
220
|
+
input.id,
|
|
221
|
+
name,
|
|
222
|
+
description,
|
|
223
|
+
content,
|
|
224
|
+
input.enabled ?? current.enabled,
|
|
225
|
+
input.baseVersion,
|
|
226
|
+
],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (!result.rows[0]) {
|
|
230
|
+
throw new Error(`VERSION_CONFLICT: Prompt fragment has changed: ${input.id}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return mapPromptFragmentRow(result.rows[0]);
|
|
234
|
+
} finally {
|
|
235
|
+
client.release();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async delete(id: string) {
|
|
240
|
+
const client = await this.pool.connect();
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
await client.query(
|
|
244
|
+
`
|
|
245
|
+
update agent.agent_prompt_fragments
|
|
246
|
+
set
|
|
247
|
+
enabled = false,
|
|
248
|
+
version = version + 1,
|
|
249
|
+
updated_at = now()
|
|
250
|
+
where id = $1
|
|
251
|
+
`,
|
|
252
|
+
[id],
|
|
253
|
+
);
|
|
254
|
+
} finally {
|
|
255
|
+
client.release();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type DefaultPromptRegistryDependencies = {
|
|
261
|
+
store: PromptFragmentStore;
|
|
262
|
+
generators?: GeneratorRegistry;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export class InMemoryGeneratorRegistry implements GeneratorRegistry {
|
|
266
|
+
private readonly generators = new Map<string, PromptGenerator>();
|
|
267
|
+
|
|
268
|
+
get(id: string) {
|
|
269
|
+
return this.generators.get(id);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
register(generator: PromptGenerator) {
|
|
273
|
+
if (this.generators.has(generator.id)) {
|
|
274
|
+
throw new Error(`Duplicate generator id: ${generator.id}`);
|
|
275
|
+
}
|
|
276
|
+
this.generators.set(generator.id, generator);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readContextValue(context: Record<string, unknown>, path: string) {
|
|
281
|
+
if (path === '.') {
|
|
282
|
+
return context;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return path
|
|
286
|
+
.split('.')
|
|
287
|
+
.reduce<unknown>((current, segment) => (
|
|
288
|
+
current && typeof current === 'object' && !Array.isArray(current)
|
|
289
|
+
? (current as Record<string, unknown>)[segment]
|
|
290
|
+
: undefined
|
|
291
|
+
), context);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type TemplateNode =
|
|
295
|
+
| { type: 'text'; value: string }
|
|
296
|
+
| { type: 'variable'; key: string }
|
|
297
|
+
| { type: 'section'; key: string; inverted: boolean; children: TemplateNode[] };
|
|
298
|
+
|
|
299
|
+
const templateTagPattern = /\{\{\s*([#^/]?)\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
|
|
300
|
+
|
|
301
|
+
function parseTemplate(template: string) {
|
|
302
|
+
const root: TemplateNode[] = [];
|
|
303
|
+
const sectionStack: Array<{ key: string; inverted: boolean; children: TemplateNode[] }> = [];
|
|
304
|
+
let cursor = 0;
|
|
305
|
+
|
|
306
|
+
const appendNode = (node: TemplateNode) => {
|
|
307
|
+
if (sectionStack.length > 0) {
|
|
308
|
+
sectionStack[sectionStack.length - 1]?.children.push(node);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
root.push(node);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
for (const match of template.matchAll(templateTagPattern)) {
|
|
315
|
+
const fullMatch = match[0];
|
|
316
|
+
const marker = match[1] ?? '';
|
|
317
|
+
const key = match[2] ?? '';
|
|
318
|
+
const matchIndex = match.index ?? 0;
|
|
319
|
+
|
|
320
|
+
if (matchIndex > cursor) {
|
|
321
|
+
appendNode({
|
|
322
|
+
type: 'text',
|
|
323
|
+
value: template.slice(cursor, matchIndex),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
cursor = matchIndex + fullMatch.length;
|
|
328
|
+
|
|
329
|
+
if (marker === '#') {
|
|
330
|
+
sectionStack.push({
|
|
331
|
+
key,
|
|
332
|
+
inverted: false,
|
|
333
|
+
children: [],
|
|
334
|
+
});
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (marker === '^') {
|
|
339
|
+
sectionStack.push({
|
|
340
|
+
key,
|
|
341
|
+
inverted: true,
|
|
342
|
+
children: [],
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (marker === '/') {
|
|
348
|
+
const closed = sectionStack.pop();
|
|
349
|
+
if (!closed || closed.key !== key) {
|
|
350
|
+
throw new Error(`Invalid prompt template section close tag: ${key}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
appendNode({
|
|
354
|
+
type: 'section',
|
|
355
|
+
key,
|
|
356
|
+
inverted: closed.inverted,
|
|
357
|
+
children: closed.children,
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
appendNode({
|
|
363
|
+
type: 'variable',
|
|
364
|
+
key,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (cursor < template.length) {
|
|
369
|
+
appendNode({
|
|
370
|
+
type: 'text',
|
|
371
|
+
value: template.slice(cursor),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (sectionStack.length > 0) {
|
|
376
|
+
throw new Error(`Invalid prompt template: unclosed section ${sectionStack[sectionStack.length - 1]?.key}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return root;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readContextValueFromStack(stack: unknown[], key: string): unknown {
|
|
383
|
+
if (key === '.') {
|
|
384
|
+
return stack[stack.length - 1];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
388
|
+
const frame = stack[index];
|
|
389
|
+
if (frame && typeof frame === 'object' && !Array.isArray(frame)) {
|
|
390
|
+
const value = readContextValue(frame as Record<string, unknown>, key);
|
|
391
|
+
if (value !== undefined) {
|
|
392
|
+
return value;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isTruthySectionValue(value: unknown) {
|
|
401
|
+
if (Array.isArray(value)) {
|
|
402
|
+
return value.length > 0;
|
|
403
|
+
}
|
|
404
|
+
return Boolean(value);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function renderNodes(nodes: TemplateNode[], stack: unknown[]): string {
|
|
408
|
+
return nodes.map((node) => {
|
|
409
|
+
if (node.type === 'text') {
|
|
410
|
+
return node.value;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (node.type === 'variable') {
|
|
414
|
+
const value = readContextValueFromStack(stack, node.key);
|
|
415
|
+
return value === undefined || value === null ? '' : String(value);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const value = readContextValueFromStack(stack, node.key);
|
|
419
|
+
if (node.inverted) {
|
|
420
|
+
return isTruthySectionValue(value) ? '' : renderNodes(node.children, stack);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (Array.isArray(value)) {
|
|
424
|
+
return value.map((item) => renderNodes(
|
|
425
|
+
node.children,
|
|
426
|
+
item && typeof item === 'object' ? [...stack, item] : [...stack, item],
|
|
427
|
+
)).join('');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!isTruthySectionValue(value)) {
|
|
431
|
+
return '';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (value && typeof value === 'object') {
|
|
435
|
+
return renderNodes(node.children, [...stack, value]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return renderNodes(node.children, stack);
|
|
439
|
+
}).join('');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function renderTemplate(template: string, context: Record<string, unknown>) {
|
|
443
|
+
return renderNodes(parseTemplate(template), [context]);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export class DefaultPromptRegistry implements PromptRegistry {
|
|
447
|
+
private readonly generators: GeneratorRegistry;
|
|
448
|
+
|
|
449
|
+
constructor(private readonly dependencies: DefaultPromptRegistryDependencies) {
|
|
450
|
+
this.generators = dependencies.generators ?? new InMemoryGeneratorRegistry();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async assemblePrompt(input: AssemblePromptInput) {
|
|
454
|
+
const persisted = await this.dependencies.store.list(input.appId
|
|
455
|
+
? { appId: input.appId, includeGlobal: true }
|
|
456
|
+
: undefined);
|
|
457
|
+
const byId = new Map<string, PromptFragmentView>(
|
|
458
|
+
persisted.map((fragment) => [`fragment:${fragment.id}`, fragment]),
|
|
459
|
+
);
|
|
460
|
+
const context = {
|
|
461
|
+
...(input.harnessMetadata ?? {}),
|
|
462
|
+
...(input.sessionMetadata ?? {}),
|
|
463
|
+
};
|
|
464
|
+
const collected: string[] = [];
|
|
465
|
+
|
|
466
|
+
for (const fragmentId of input.promptFragmentIds ?? []) {
|
|
467
|
+
if (fragmentId.startsWith('fragment:')) {
|
|
468
|
+
const persistedFragment = byId.get(fragmentId);
|
|
469
|
+
if (persistedFragment?.enabled) {
|
|
470
|
+
collected.push(renderTemplate(persistedFragment.content, context));
|
|
471
|
+
}
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (fragmentId.startsWith('generator:')) {
|
|
476
|
+
const generatorId = fragmentId.slice('generator:'.length);
|
|
477
|
+
const generator = this.generators.get(generatorId);
|
|
478
|
+
if (!generator) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const generated = await generator.generate({
|
|
483
|
+
metadata: context,
|
|
484
|
+
resources: input.resources ?? {},
|
|
485
|
+
});
|
|
486
|
+
if (generated.trim()) {
|
|
487
|
+
collected.push(generated.trim());
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
for (const fragment of input.dynamicFragments ?? []) {
|
|
493
|
+
if (fragment.trim()) {
|
|
494
|
+
collected.push(fragment.trim());
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return collected
|
|
499
|
+
.map((fragment) => fragment.trim())
|
|
500
|
+
.filter(Boolean)
|
|
501
|
+
.join('\n--------------\n');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async listPromptFragments(query?: Parameters<PromptFragmentStore['list']>[0]) {
|
|
505
|
+
const persisted = await this.dependencies.store.list(query);
|
|
506
|
+
return [...persisted];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async getPromptFragment(id: string) {
|
|
510
|
+
return this.dependencies.store.get(id);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async createPromptFragment(input: CreatePromptFragmentInput) {
|
|
514
|
+
return this.dependencies.store.create(input);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async updatePromptFragment(input: UpdatePromptFragmentInput) {
|
|
518
|
+
return this.dependencies.store.update(input);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async deletePromptFragment(id: string) {
|
|
522
|
+
return this.dependencies.store.delete(id);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function createDefaultPromptRegistry(dependencies: DefaultPromptRegistryDependencies) {
|
|
528
|
+
return new DefaultPromptRegistry(dependencies);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export { PostgresSkillStore } from './postgres-skill-store.js';
|
|
532
|
+
export * from './load-skill.js';
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
HarnessConfig,
|
|
4
|
+
HarnessConfigStore,
|
|
5
|
+
RuntimeSkillView,
|
|
6
|
+
SkillResource,
|
|
7
|
+
SkillStore,
|
|
8
|
+
SkillView,
|
|
9
|
+
ToolDescriptor,
|
|
10
|
+
ToolDescriptorContext,
|
|
11
|
+
ToolHandler,
|
|
12
|
+
} from '@ct-agents/protocol';
|
|
13
|
+
|
|
14
|
+
export const loadSkillToolInputSchema = z.object({
|
|
15
|
+
skillName: z.string().trim().min(1),
|
|
16
|
+
}).strict();
|
|
17
|
+
|
|
18
|
+
export type LoadSkillToolInput = z.infer<typeof loadSkillToolInputSchema>;
|
|
19
|
+
|
|
20
|
+
export type LoadSkillCatalogItem = {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
version: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type LoadedSkill = LoadSkillCatalogItem & {
|
|
27
|
+
instructions: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type LoadSkillToolResult = {
|
|
31
|
+
status: 'ok' | 'warning';
|
|
32
|
+
message?: string;
|
|
33
|
+
skill?: LoadedSkill;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type LoadSkillToolDeps = {
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const catalogItemSchema = z.object({
|
|
40
|
+
name: z.string(),
|
|
41
|
+
description: z.string(),
|
|
42
|
+
version: z.number().int().positive(),
|
|
43
|
+
}).strict();
|
|
44
|
+
|
|
45
|
+
export const loadSkillToolResultSchema: z.ZodType<LoadSkillToolResult> = z.object({
|
|
46
|
+
status: z.enum(['ok', 'warning']),
|
|
47
|
+
message: z.string().optional(),
|
|
48
|
+
skill: catalogItemSchema.extend({
|
|
49
|
+
instructions: z.string(),
|
|
50
|
+
}).strict().optional(),
|
|
51
|
+
}).strict();
|
|
52
|
+
|
|
53
|
+
function getErrorMessage(error: unknown) {
|
|
54
|
+
return error instanceof Error ? error.message : String(error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toCatalogItem(skill: Pick<RuntimeSkillView, 'name' | 'description' | 'version'>): LoadSkillCatalogItem {
|
|
58
|
+
return {
|
|
59
|
+
name: skill.name,
|
|
60
|
+
description: skill.description,
|
|
61
|
+
version: skill.version,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toLoadedSkill(skill: RuntimeSkillView): LoadedSkill {
|
|
66
|
+
return {
|
|
67
|
+
...toCatalogItem(skill),
|
|
68
|
+
instructions: skill.instructions,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readHarnessSkillName(value: Record<string, unknown>) {
|
|
73
|
+
const name = value.name;
|
|
74
|
+
return typeof name === 'string' && name.trim() ? name.trim() : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 解析 harness 配置中关联的 skill name(去重、保序)。 */
|
|
78
|
+
export function resolveHarnessSkillNames(harness: HarnessConfig) {
|
|
79
|
+
const skillNames: string[] = [];
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
|
|
82
|
+
for (const entry of harness.skills) {
|
|
83
|
+
const skillName = readHarnessSkillName(entry);
|
|
84
|
+
if (!skillName || seen.has(skillName)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
seen.add(skillName);
|
|
89
|
+
skillNames.push(skillName);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return skillNames;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readHarnessConfig(input: {
|
|
96
|
+
harnessStore: HarnessConfigStore;
|
|
97
|
+
appId?: string;
|
|
98
|
+
harnessId: string;
|
|
99
|
+
harnessVersion: number;
|
|
100
|
+
}) {
|
|
101
|
+
return input.harnessStore.get({
|
|
102
|
+
appId: input.appId,
|
|
103
|
+
id: input.harnessId,
|
|
104
|
+
version: input.harnessVersion,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 读取 harness 关联且当前启用的 Skill 目录(仅元数据,不含 instructions)。 */
|
|
109
|
+
export async function readAvailableSkills(input: {
|
|
110
|
+
skillStore: SkillStore;
|
|
111
|
+
skillNames: string[];
|
|
112
|
+
}) {
|
|
113
|
+
const enabledSkills = await input.skillStore.list();
|
|
114
|
+
const enabledByName = new Map(enabledSkills.map((skill) => [skill.name, skill]));
|
|
115
|
+
return input.skillNames
|
|
116
|
+
.map((skillName) => enabledByName.get(skillName))
|
|
117
|
+
.filter((skill): skill is SkillView => Boolean(skill))
|
|
118
|
+
.map(toCatalogItem);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 构造 harness 关联 Skill 的 markdown 目录,供 {@link createLoadSkillToolHandler} 注入 description。
|
|
123
|
+
*
|
|
124
|
+
* 只列关联且启用的 Skill;读取失败或无可用 Skill 时返回空串,让调用方回退静态 description。
|
|
125
|
+
*/
|
|
126
|
+
export async function buildHarnessSkillCatalogMarkdown(input: {
|
|
127
|
+
harnessStore: HarnessConfigStore;
|
|
128
|
+
skillStore: SkillStore;
|
|
129
|
+
appId?: string;
|
|
130
|
+
harnessId: string;
|
|
131
|
+
harnessVersion: number;
|
|
132
|
+
}): Promise<string> {
|
|
133
|
+
const harnessConfig = await readHarnessConfig(input);
|
|
134
|
+
if (!harnessConfig) {
|
|
135
|
+
return '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const items = await readAvailableSkills({
|
|
139
|
+
skillStore: input.skillStore,
|
|
140
|
+
skillNames: resolveHarnessSkillNames(harnessConfig),
|
|
141
|
+
});
|
|
142
|
+
if (items.length === 0) {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return items
|
|
147
|
+
.map((item) => `- ${item.name}:${item.description}`)
|
|
148
|
+
.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function warning(message: string): LoadSkillToolResult {
|
|
152
|
+
return { status: 'warning', message };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const loadSkillDescriptor: ToolDescriptor = {
|
|
156
|
+
name: 'load-skill',
|
|
157
|
+
title: '读取 Skill',
|
|
158
|
+
description: [
|
|
159
|
+
'按 skillName 读取一个 Skill 的完整 instructions。',
|
|
160
|
+
'当前 harness 可用的 Skill 目录已注入本工具说明(见下方)。',
|
|
161
|
+
].join('\n'),
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
additionalProperties: false,
|
|
165
|
+
required: ['skillName'],
|
|
166
|
+
properties: {
|
|
167
|
+
skillName: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
description: '要读取的 Skill 名称(必填,即 Skill 唯一标识)。',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
resultSchema: {
|
|
174
|
+
type: 'object',
|
|
175
|
+
additionalProperties: false,
|
|
176
|
+
required: ['status'],
|
|
177
|
+
properties: {
|
|
178
|
+
status: { enum: ['ok', 'warning'] },
|
|
179
|
+
message: { type: 'string' },
|
|
180
|
+
skill: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
additionalProperties: false,
|
|
183
|
+
required: ['name', 'description', 'instructions', 'version'],
|
|
184
|
+
properties: {
|
|
185
|
+
name: { type: 'string' },
|
|
186
|
+
description: { type: 'string' },
|
|
187
|
+
instructions: { type: 'string' },
|
|
188
|
+
version: { type: 'integer' },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
annotations: {
|
|
194
|
+
readOnly: true,
|
|
195
|
+
idempotent: true,
|
|
196
|
+
openWorld: false,
|
|
197
|
+
destructive: false,
|
|
198
|
+
},
|
|
199
|
+
requiredResources: ['skills'],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 按 skillId 读取当前 harness 关联的 Skill。
|
|
204
|
+
*
|
|
205
|
+
* 工具本身只「按 id 读一个 Skill」;可用 Skill 目录由 {@link resolveDescriptor} 注入自身 description。
|
|
206
|
+
* 读取失败、未关联、禁用或缺失都返回 warning 给模型继续处理,不抛业务错误,
|
|
207
|
+
* 避免因为可选能力不可用而让整个 session 进入 error。
|
|
208
|
+
*/
|
|
209
|
+
async function buildResourceSkillCatalogMarkdown(skills: SkillResource): Promise<string> {
|
|
210
|
+
const items = await skills.list();
|
|
211
|
+
if (items.length === 0) {
|
|
212
|
+
return '';
|
|
213
|
+
}
|
|
214
|
+
return items
|
|
215
|
+
.map((item) => `- ${item.name}:${item.description}`)
|
|
216
|
+
.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function createLoadSkillToolHandler(_deps: LoadSkillToolDeps = {}): ToolHandler<LoadSkillToolInput, LoadSkillToolResult> {
|
|
220
|
+
return {
|
|
221
|
+
descriptor: loadSkillDescriptor,
|
|
222
|
+
// 把当前 resource 已过滤的 Skill 目录注入自身 description;读取失败/无可用 Skill 时回退静态 descriptor。
|
|
223
|
+
async resolveDescriptor(context: ToolDescriptorContext) {
|
|
224
|
+
const skills = context.resources.skills;
|
|
225
|
+
if (!skills) {
|
|
226
|
+
return loadSkillDescriptor;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const catalog = await buildResourceSkillCatalogMarkdown(skills);
|
|
230
|
+
if (!catalog.trim()) {
|
|
231
|
+
return loadSkillDescriptor;
|
|
232
|
+
}
|
|
233
|
+
// 把可用 Skill 目录拼到自身 description 末尾,供模型决定调用哪个 skillId。
|
|
234
|
+
const heading = '当前 harness 可用 Skill 目录(传 skillName 调 load-skill 读完整 instructions):';
|
|
235
|
+
return {
|
|
236
|
+
...loadSkillDescriptor,
|
|
237
|
+
description: [loadSkillDescriptor.description, heading, catalog.trim()].join('\n\n'),
|
|
238
|
+
};
|
|
239
|
+
} catch {
|
|
240
|
+
return loadSkillDescriptor;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
inputSchema: loadSkillToolInputSchema,
|
|
244
|
+
resultSchema: loadSkillToolResultSchema,
|
|
245
|
+
async execute(input, context) {
|
|
246
|
+
const skills = context.resources.skills;
|
|
247
|
+
if (!skills) {
|
|
248
|
+
return {
|
|
249
|
+
status: 'completed',
|
|
250
|
+
modelResult: warning('load-skill 未配置 skills resource。'),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let skill: RuntimeSkillView | null;
|
|
255
|
+
try {
|
|
256
|
+
skill = await skills.get(input.skillName);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return {
|
|
259
|
+
status: 'completed',
|
|
260
|
+
modelResult: warning(`读取 Skill 失败:${getErrorMessage(error)}`),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!skill) {
|
|
265
|
+
return {
|
|
266
|
+
status: 'completed',
|
|
267
|
+
modelResult: warning(`Skill 不存在:${input.skillName}`),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!skill.enabled) {
|
|
272
|
+
return {
|
|
273
|
+
status: 'completed',
|
|
274
|
+
modelResult: warning(`Skill 已禁用:${input.skillName}`),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
status: 'completed',
|
|
280
|
+
modelResult: {
|
|
281
|
+
status: 'ok',
|
|
282
|
+
skill: toLoadedSkill(skill),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解析 frontmatter 中的单个标量值。
|
|
3
|
+
*
|
|
4
|
+
* 只支持当前 agent/prompt 资产需要的布尔、数字、null、JSON 对象/数组和字符串。
|
|
5
|
+
*/
|
|
6
|
+
function parseFrontmatterScalar(value: string): boolean | number | string | null | object | unknown[] {
|
|
7
|
+
const normalized = value.trim();
|
|
8
|
+
if (!normalized) {
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (normalized === 'true') {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (normalized === 'false') {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (normalized === 'null') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
normalized.startsWith('{')
|
|
26
|
+
|| normalized.startsWith('[')
|
|
27
|
+
|| normalized.startsWith('"')
|
|
28
|
+
) {
|
|
29
|
+
return JSON.parse(normalized) as object | unknown[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const numericValue = Number(normalized);
|
|
33
|
+
if (Number.isFinite(numericValue) && String(numericValue) === normalized) {
|
|
34
|
+
return numericValue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 解析带 YAML 风格 frontmatter 的 Markdown 资产。
|
|
42
|
+
*/
|
|
43
|
+
export function parseMarkdownFrontmatter(content: string) {
|
|
44
|
+
const normalizedContent = content.replace(/\r\n/g, '\n');
|
|
45
|
+
if (!normalizedContent.startsWith('---\n')) {
|
|
46
|
+
throw new Error('Markdown frontmatter 缺失');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const closingIndex = normalizedContent.indexOf('\n---\n', 4);
|
|
50
|
+
if (closingIndex === -1) {
|
|
51
|
+
throw new Error('Markdown frontmatter 未正确闭合');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rawFrontmatter = normalizedContent.slice(4, closingIndex).trim();
|
|
55
|
+
const body = normalizedContent.slice(closingIndex + 5).trim();
|
|
56
|
+
const frontmatter: Record<string, unknown> = {};
|
|
57
|
+
|
|
58
|
+
for (const line of rawFrontmatter.split('\n')) {
|
|
59
|
+
const trimmedLine = line.trim();
|
|
60
|
+
if (!trimmedLine) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const separatorIndex = trimmedLine.indexOf(':');
|
|
65
|
+
if (separatorIndex === -1) {
|
|
66
|
+
throw new Error(`无效的 frontmatter 行:${trimmedLine}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const key = trimmedLine.slice(0, separatorIndex).trim();
|
|
70
|
+
const value = trimmedLine.slice(separatorIndex + 1).trim();
|
|
71
|
+
frontmatter[key] = parseFrontmatterScalar(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
frontmatter,
|
|
76
|
+
body,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { SqlPool } from '@ct-agents/store';
|
|
2
|
+
import type {
|
|
3
|
+
CreateSkillInput,
|
|
4
|
+
SkillListQuery,
|
|
5
|
+
SkillStore,
|
|
6
|
+
SkillView,
|
|
7
|
+
UpdateSkillInput,
|
|
8
|
+
} from '@ct-agents/protocol';
|
|
9
|
+
|
|
10
|
+
type SkillRow = {
|
|
11
|
+
app_id?: string | null;
|
|
12
|
+
version: number;
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
instructions: string;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
created_at?: string | Date | null;
|
|
18
|
+
updated_at?: string | Date | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function toIsoString(value: string | Date | null | undefined) {
|
|
22
|
+
if (!value) return undefined;
|
|
23
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mapSkillRow(row: SkillRow): SkillView {
|
|
27
|
+
return {
|
|
28
|
+
appId: row.app_id ?? null,
|
|
29
|
+
version: row.version,
|
|
30
|
+
name: row.name,
|
|
31
|
+
description: row.description,
|
|
32
|
+
instructions: row.instructions,
|
|
33
|
+
enabled: row.enabled,
|
|
34
|
+
createdAt: toIsoString(row.created_at),
|
|
35
|
+
updatedAt: toIsoString(row.updated_at),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class PostgresSkillStore implements SkillStore {
|
|
40
|
+
constructor(private readonly pool: SqlPool) {}
|
|
41
|
+
|
|
42
|
+
async list(query?: SkillListQuery): Promise<SkillView[]> {
|
|
43
|
+
const client = await this.pool.connect();
|
|
44
|
+
try {
|
|
45
|
+
// 与 PostgresPromptFragmentStore 保持一致的 app 归属过滤:
|
|
46
|
+
// 指定 appId 时按 app 精确过滤,includeGlobal 时额外纳入 global(app_id is null)。
|
|
47
|
+
const conditions = ['enabled = true'];
|
|
48
|
+
const params: unknown[] = [];
|
|
49
|
+
if (query?.appId) {
|
|
50
|
+
params.push(query.appId);
|
|
51
|
+
conditions.push(query.includeGlobal ? '(app_id = $1 or app_id is null)' : 'app_id = $1');
|
|
52
|
+
}
|
|
53
|
+
const result = await client.query<SkillRow>(`
|
|
54
|
+
select app_id, version, name, description, instructions, enabled, created_at, updated_at
|
|
55
|
+
from agent.agent_skills
|
|
56
|
+
where ${conditions.join(' and ')}
|
|
57
|
+
order by name asc
|
|
58
|
+
`, params);
|
|
59
|
+
return result.rows.map(mapSkillRow);
|
|
60
|
+
} finally {
|
|
61
|
+
client.release();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async get(name: string): Promise<SkillView | null> {
|
|
66
|
+
const client = await this.pool.connect();
|
|
67
|
+
try {
|
|
68
|
+
const result = await client.query<SkillRow>(`
|
|
69
|
+
select app_id, version, name, description, instructions, enabled, created_at, updated_at
|
|
70
|
+
from agent.agent_skills
|
|
71
|
+
where name = $1
|
|
72
|
+
limit 1
|
|
73
|
+
`, [name]);
|
|
74
|
+
return result.rows[0] ? mapSkillRow(result.rows[0]) : null;
|
|
75
|
+
} finally {
|
|
76
|
+
client.release();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async create(input: CreateSkillInput): Promise<SkillView> {
|
|
81
|
+
const client = await this.pool.connect();
|
|
82
|
+
try {
|
|
83
|
+
const result = await client.query<SkillRow>(`
|
|
84
|
+
insert into agent.agent_skills (
|
|
85
|
+
app_id,
|
|
86
|
+
name,
|
|
87
|
+
description,
|
|
88
|
+
instructions,
|
|
89
|
+
enabled
|
|
90
|
+
)
|
|
91
|
+
values ($1, $2, $3, $4, $5)
|
|
92
|
+
returning app_id, version, name, description, instructions, enabled, created_at, updated_at
|
|
93
|
+
`, [
|
|
94
|
+
input.appId ?? null,
|
|
95
|
+
input.name.trim(),
|
|
96
|
+
input.description.trim(),
|
|
97
|
+
input.instructions.trim(),
|
|
98
|
+
input.enabled ?? true,
|
|
99
|
+
]);
|
|
100
|
+
return mapSkillRow(result.rows[0]);
|
|
101
|
+
} finally {
|
|
102
|
+
client.release();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async update(input: UpdateSkillInput): Promise<SkillView> {
|
|
107
|
+
const current = await this.get(input.name);
|
|
108
|
+
if (!current) {
|
|
109
|
+
throw new Error(`Skill not found: ${input.name}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const client = await this.pool.connect();
|
|
113
|
+
try {
|
|
114
|
+
// name 为主键不可改,仅作定位;description/instructions/enabled 可更新。
|
|
115
|
+
const result = await client.query<SkillRow>(`
|
|
116
|
+
update agent.agent_skills
|
|
117
|
+
set
|
|
118
|
+
description = $2,
|
|
119
|
+
instructions = $3,
|
|
120
|
+
enabled = $4,
|
|
121
|
+
version = version + 1,
|
|
122
|
+
updated_at = now()
|
|
123
|
+
where name = $1 and version = $5
|
|
124
|
+
returning app_id, version, name, description, instructions, enabled, created_at, updated_at
|
|
125
|
+
`, [
|
|
126
|
+
input.name,
|
|
127
|
+
input.description === undefined ? current.description : input.description.trim(),
|
|
128
|
+
input.instructions === undefined ? current.instructions : input.instructions.trim(),
|
|
129
|
+
input.enabled ?? current.enabled,
|
|
130
|
+
input.baseVersion,
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
if (!result.rows[0]) {
|
|
134
|
+
throw new Error(`VERSION_CONFLICT: Skill has changed: ${input.name}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return mapSkillRow(result.rows[0]);
|
|
138
|
+
} finally {
|
|
139
|
+
client.release();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async delete(name: string): Promise<void> {
|
|
144
|
+
const client = await this.pool.connect();
|
|
145
|
+
try {
|
|
146
|
+
await client.query(`
|
|
147
|
+
update agent.agent_skills
|
|
148
|
+
set
|
|
149
|
+
enabled = false,
|
|
150
|
+
version = version + 1,
|
|
151
|
+
updated_at = now()
|
|
152
|
+
where name = $1
|
|
153
|
+
`, [name]);
|
|
154
|
+
} finally {
|
|
155
|
+
client.release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
CreatePromptFragmentInput,
|
|
4
|
+
CreateSkillInput,
|
|
5
|
+
PromptFragmentStore,
|
|
6
|
+
PromptFragmentView,
|
|
7
|
+
SkillStore,
|
|
8
|
+
SkillView,
|
|
9
|
+
UpdateSkillInput,
|
|
10
|
+
UpdatePromptFragmentInput,
|
|
11
|
+
} from '@ct-agents/protocol';
|
|
12
|
+
import {
|
|
13
|
+
DefaultPromptRegistry,
|
|
14
|
+
type DefaultPromptRegistryDependencies,
|
|
15
|
+
} from './index.js';
|
|
16
|
+
|
|
17
|
+
export class InMemoryPromptFragmentStore implements PromptFragmentStore {
|
|
18
|
+
private readonly fragments = new Map<string, PromptFragmentView>();
|
|
19
|
+
|
|
20
|
+
async list(query?: { appId?: string; includeGlobal?: boolean }) {
|
|
21
|
+
return Array.from(this.fragments.values())
|
|
22
|
+
.filter((fragment) => fragment.enabled)
|
|
23
|
+
.filter((fragment) => {
|
|
24
|
+
if (!query?.appId) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return query.includeGlobal
|
|
28
|
+
? fragment.appId === query.appId || fragment.appId === null
|
|
29
|
+
: fragment.appId === query.appId;
|
|
30
|
+
})
|
|
31
|
+
.map((fragment) => structuredClone(fragment))
|
|
32
|
+
.sort((left, right) => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? ''));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async get(id: string) {
|
|
36
|
+
const fragment = this.fragments.get(id);
|
|
37
|
+
return fragment ? structuredClone(fragment) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async create(input: CreatePromptFragmentInput) {
|
|
41
|
+
const now = new Date().toISOString();
|
|
42
|
+
const fragment: PromptFragmentView = {
|
|
43
|
+
appId: input.appId ?? null,
|
|
44
|
+
id: `persisted:${randomUUID()}`,
|
|
45
|
+
version: 1,
|
|
46
|
+
name: input.name.trim(),
|
|
47
|
+
...(input.description?.trim() ? { description: input.description.trim() } : {}),
|
|
48
|
+
content: input.content.trim(),
|
|
49
|
+
enabled: input.enabled ?? true,
|
|
50
|
+
source: {
|
|
51
|
+
kind: 'persisted',
|
|
52
|
+
},
|
|
53
|
+
createdAt: now,
|
|
54
|
+
updatedAt: now,
|
|
55
|
+
};
|
|
56
|
+
this.fragments.set(fragment.id, fragment);
|
|
57
|
+
return structuredClone(fragment);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async update(input: UpdatePromptFragmentInput) {
|
|
61
|
+
const current = this.fragments.get(input.id);
|
|
62
|
+
if (!current) {
|
|
63
|
+
throw new Error(`Prompt fragment not found: ${input.id}`);
|
|
64
|
+
}
|
|
65
|
+
if (current.version !== input.baseVersion) {
|
|
66
|
+
throw new Error(`VERSION_CONFLICT: Prompt fragment has changed: ${input.id}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const name = input.name === undefined ? current.name : input.name.trim();
|
|
70
|
+
const description = input.description === undefined
|
|
71
|
+
? current.description
|
|
72
|
+
: input.description.trim() || undefined;
|
|
73
|
+
const content = input.content === undefined ? current.content : input.content.trim();
|
|
74
|
+
const next: PromptFragmentView = {
|
|
75
|
+
...current,
|
|
76
|
+
version: current.version + 1,
|
|
77
|
+
name,
|
|
78
|
+
...(description !== undefined ? { description } : {}),
|
|
79
|
+
content,
|
|
80
|
+
enabled: input.enabled ?? current.enabled,
|
|
81
|
+
updatedAt: new Date().toISOString(),
|
|
82
|
+
};
|
|
83
|
+
this.fragments.set(input.id, next);
|
|
84
|
+
return structuredClone(next);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async delete(id: string) {
|
|
88
|
+
const current = this.fragments.get(id);
|
|
89
|
+
if (!current) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.fragments.set(id, {
|
|
93
|
+
...current,
|
|
94
|
+
version: current.version + 1,
|
|
95
|
+
enabled: false,
|
|
96
|
+
updatedAt: new Date().toISOString(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class InMemorySkillStore implements SkillStore {
|
|
102
|
+
private readonly skills = new Map<string, SkillView>();
|
|
103
|
+
|
|
104
|
+
async list(query?: { appId?: string; includeGlobal?: boolean }) {
|
|
105
|
+
return Array.from(this.skills.values())
|
|
106
|
+
.filter((skill) => skill.enabled)
|
|
107
|
+
.filter((skill) => {
|
|
108
|
+
if (!query?.appId) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return query.includeGlobal
|
|
112
|
+
? skill.appId === query.appId || skill.appId === null
|
|
113
|
+
: skill.appId === query.appId;
|
|
114
|
+
})
|
|
115
|
+
.map((skill) => structuredClone(skill));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async get(name: string) {
|
|
119
|
+
const skill = this.skills.get(name);
|
|
120
|
+
return skill ? structuredClone(skill) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async create(input: CreateSkillInput) {
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
const skill: SkillView = {
|
|
126
|
+
appId: input.appId ?? null,
|
|
127
|
+
name: input.name.trim(),
|
|
128
|
+
version: 1,
|
|
129
|
+
description: input.description.trim(),
|
|
130
|
+
instructions: input.instructions.trim(),
|
|
131
|
+
enabled: input.enabled ?? true,
|
|
132
|
+
createdAt: now,
|
|
133
|
+
updatedAt: now,
|
|
134
|
+
};
|
|
135
|
+
this.skills.set(skill.name, skill);
|
|
136
|
+
return structuredClone(skill);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async update(input: UpdateSkillInput) {
|
|
140
|
+
const current = this.skills.get(input.name);
|
|
141
|
+
if (!current) {
|
|
142
|
+
throw new Error(`Skill not found: ${input.name}`);
|
|
143
|
+
}
|
|
144
|
+
if (current.version !== input.baseVersion) {
|
|
145
|
+
throw new Error(`VERSION_CONFLICT: Skill has changed: ${input.name}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const next: SkillView = {
|
|
149
|
+
...current,
|
|
150
|
+
version: current.version + 1,
|
|
151
|
+
description: input.description === undefined ? current.description : input.description.trim(),
|
|
152
|
+
instructions: input.instructions === undefined ? current.instructions : input.instructions.trim(),
|
|
153
|
+
enabled: input.enabled ?? current.enabled,
|
|
154
|
+
updatedAt: new Date().toISOString(),
|
|
155
|
+
};
|
|
156
|
+
this.skills.set(input.name, next);
|
|
157
|
+
return structuredClone(next);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async delete(name: string) {
|
|
161
|
+
const current = this.skills.get(name);
|
|
162
|
+
if (!current) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.skills.set(name, {
|
|
166
|
+
...current,
|
|
167
|
+
version: current.version + 1,
|
|
168
|
+
enabled: false,
|
|
169
|
+
updatedAt: new Date().toISOString(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createInMemoryPromptRegistry() {
|
|
175
|
+
return new DefaultPromptRegistry({
|
|
176
|
+
store: new InMemoryPromptFragmentStore(),
|
|
177
|
+
} as DefaultPromptRegistryDependencies);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export {
|
|
181
|
+
createDefaultPromptRegistry,
|
|
182
|
+
InMemoryGeneratorRegistry,
|
|
183
|
+
} from './index.js';
|