mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
♻️ refactor(server,prompts,builtin-tool-skill-maintainer): correct context passing, skill format, chained (#14397)
This commit is contained in:
@@ -228,6 +228,7 @@
|
||||
"@lobechat/builtin-tool-notebook": "workspace:*",
|
||||
"@lobechat/builtin-tool-page-agent": "workspace:*",
|
||||
"@lobechat/builtin-tool-remote-device": "workspace:*",
|
||||
"@lobechat/builtin-tool-skill-maintainer": "workspace:*",
|
||||
"@lobechat/builtin-tool-skill-store": "workspace:*",
|
||||
"@lobechat/builtin-tool-skills": "workspace:*",
|
||||
"@lobechat/builtin-tool-task": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AgentDocumentsExecutionRuntime } from './index';
|
||||
|
||||
const createRuntime = (overrides = {}) =>
|
||||
new AgentDocumentsExecutionRuntime({
|
||||
copyDocument: vi.fn(),
|
||||
createDocument: vi.fn(),
|
||||
createTopicDocument: vi.fn(),
|
||||
listDocuments: vi.fn(),
|
||||
listTopicDocuments: vi.fn(),
|
||||
modifyNodes: vi.fn(),
|
||||
readDocument: vi.fn(),
|
||||
removeDocument: vi.fn(),
|
||||
renameDocument: vi.fn(),
|
||||
replaceDocumentContent: vi.fn(),
|
||||
updateLoadRule: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AgentDocumentsExecutionRuntime', () => {
|
||||
it('returns agentDocumentId and documentId when creating hinted documents', async () => {
|
||||
const createDocument = vi.fn().mockResolvedValue({
|
||||
documentId: 'backing-doc-1',
|
||||
id: 'agent-doc-1',
|
||||
title: 'Reusable Procedure',
|
||||
});
|
||||
const runtime = createRuntime({ createDocument });
|
||||
|
||||
const result = await runtime.createDocument(
|
||||
{
|
||||
content: 'steps',
|
||||
hintIsSkill: true,
|
||||
title: 'Reusable Procedure',
|
||||
},
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(createDocument).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
content: 'steps',
|
||||
hintIsSkill: true,
|
||||
title: 'Reusable Procedure',
|
||||
});
|
||||
expect(result.state).toMatchObject({
|
||||
agentDocumentId: 'agent-doc-1',
|
||||
documentId: 'backing-doc-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -228,7 +228,7 @@ export class AgentDocumentsExecutionRuntime {
|
||||
|
||||
return {
|
||||
content: `Created document "${created.title || args.title}" (${created.id}).`,
|
||||
state: { documentId: created.documentId },
|
||||
state: { agentDocumentId: created.id, documentId: created.documentId },
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ export const AgentDocumentsManifest: BuiltinToolManifest = {
|
||||
description: 'Document content in markdown or plain text.',
|
||||
type: 'string',
|
||||
},
|
||||
hintIsSkill: {
|
||||
default: false,
|
||||
description:
|
||||
'Set true only when the document captures reusable procedural knowledge or durable agent behavior.',
|
||||
type: 'boolean',
|
||||
},
|
||||
target: {
|
||||
default: 'agent',
|
||||
description:
|
||||
|
||||
@@ -22,6 +22,8 @@ export const systemPrompt = `You have access to an Agent Documents tool for crea
|
||||
<tool_selection_guidelines>
|
||||
- By default, if the user does not explicitly specify otherwise, and the relevant Agent Documents tool is available for the task, prefer Agent Documents over Cloud Sandbox because it is easier for collaboration and multi-agent coordination.
|
||||
- **createDocument**: create a new document with title + content. Use target="currentTopic" only when the user asks to create a document in the current topic; otherwise omit target for an agent-scoped document.
|
||||
- Set hintIsSkill=true only when creating a document that contains reusable procedural knowledge, workflow instructions, tool usage guidance, or durable agent behavior. Leave ordinary notes unhinted.
|
||||
- Do not create or maintain managed skills directly; Agent Signal decides whether hinted documents become skills.
|
||||
- **listDocuments**: list agent documents. Use target="currentTopic" when the user asks about documents in the current topic. Use this to resolve a filename to a document ID before reading.
|
||||
- **readDocument**: retrieve current content by document ID. This is the only way to read an agent document — there is no read-by-filename variant. Prefer format="xml" when you may edit content, because XML includes stable node IDs. If the response contains empty content, the document is genuinely empty; do not retry with a different format or filename.
|
||||
- **modifyNodes**: preferred content-edit API. Use LiteXML insert/modify/remove operations after reading XML. For modify operations, include the existing node ID in the LiteXML.
|
||||
|
||||
@@ -14,11 +14,13 @@ export const AgentDocumentsApiName = {
|
||||
|
||||
export interface CreateDocumentArgs {
|
||||
content: string;
|
||||
hintIsSkill?: boolean;
|
||||
target?: 'agent' | 'currentTopic';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CreateDocumentState {
|
||||
agentDocumentId?: string;
|
||||
documentId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SkillMaintainerRuntimeService } from '.';
|
||||
import { SkillMaintainerExecutionRuntime } from '.';
|
||||
|
||||
const createSkill = (name = 'release-writer') => ({
|
||||
bundle: {
|
||||
agentDocumentId: `bundle-${name}`,
|
||||
documentId: `document-bundle-${name}`,
|
||||
filename: name,
|
||||
title: 'Release Writer',
|
||||
},
|
||||
description: 'Writes release notes',
|
||||
frontmatter: { description: 'Writes release notes', name },
|
||||
index: {
|
||||
agentDocumentId: `index-${name}`,
|
||||
documentId: `document-index-${name}`,
|
||||
filename: 'SKILL.md',
|
||||
title: 'SKILL.md',
|
||||
},
|
||||
name,
|
||||
title: 'Release Writer',
|
||||
});
|
||||
|
||||
const createRuntime = () => {
|
||||
const service: SkillMaintainerRuntimeService = {
|
||||
createSkill: vi.fn(async () => createSkill()),
|
||||
getSkill: vi.fn(async () => ({ ...createSkill(), content: '# Skill' })),
|
||||
listSkills: vi.fn(async () => [createSkill()]),
|
||||
renameSkill: vi.fn(async () => createSkill('renamed-skill')),
|
||||
replaceSkillIndex: vi.fn(async () => createSkill()),
|
||||
};
|
||||
|
||||
return { runtime: new SkillMaintainerExecutionRuntime(service), service };
|
||||
};
|
||||
|
||||
describe('SkillMaintainerExecutionRuntime', () => {
|
||||
/**
|
||||
* @example
|
||||
* Hidden worker calls fail closed without agent context.
|
||||
*/
|
||||
it('requires agentId context for skill-management operations', async () => {
|
||||
const { runtime, service } = createRuntime();
|
||||
|
||||
await expect(runtime.listSkills({})).resolves.toEqual({
|
||||
content: 'Cannot list managed skills without agentId context.',
|
||||
success: false,
|
||||
});
|
||||
expect(service.listSkills).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Listing returns machine-readable state with stable document ids.
|
||||
*/
|
||||
it('lists managed skills with state', async () => {
|
||||
const { runtime, service } = createRuntime();
|
||||
|
||||
const result = await runtime.listSkills({}, { agentId: 'agent-1' });
|
||||
|
||||
expect(service.listSkills).toHaveBeenCalledWith({ agentId: 'agent-1' });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
state: { skills: [createSkill()] },
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Create forwards source agent document ids, not backing document ids.
|
||||
*/
|
||||
it('creates a managed skill and preserves sourceAgentDocumentId input', async () => {
|
||||
const { runtime, service } = createRuntime();
|
||||
|
||||
const result = await runtime.createSkill(
|
||||
{
|
||||
bodyMarkdown: '# Skill',
|
||||
description: 'Writes release notes',
|
||||
name: 'release-writer',
|
||||
sourceAgentDocumentId: 'agent-doc-source',
|
||||
title: 'Release Writer',
|
||||
},
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(service.createSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'agent-1',
|
||||
sourceAgentDocumentId: 'agent-doc-source',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
state: { skill: createSkill() },
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Missing targets return a failed tool result instead of throwing.
|
||||
*/
|
||||
it('returns a failed result when a target skill is not found', async () => {
|
||||
const { runtime, service } = createRuntime();
|
||||
vi.mocked(service.getSkill).mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(runtime.getSkill({ name: 'missing' }, { agentId: 'agent-1' })).resolves.toEqual({
|
||||
content: 'Managed skill not found.',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
|
||||
|
||||
import type {
|
||||
CreateSkillArgs,
|
||||
GetSkillArgs,
|
||||
ListSkillsArgs,
|
||||
RenameSkillArgs,
|
||||
ReplaceSkillIndexArgs,
|
||||
} from '../types';
|
||||
|
||||
interface SkillMaintainerOperationContext {
|
||||
agentId?: string | null;
|
||||
}
|
||||
|
||||
interface SkillDocumentRef {
|
||||
agentDocumentId: string;
|
||||
documentId: string;
|
||||
filename: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface SkillSummary {
|
||||
bundle: SkillDocumentRef;
|
||||
description: string;
|
||||
index: SkillDocumentRef;
|
||||
name: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface SkillDetail extends SkillSummary {
|
||||
content?: string;
|
||||
frontmatter: {
|
||||
description: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Runtime service contract implemented by server-side skill-management adapters. */
|
||||
export interface SkillMaintainerRuntimeService {
|
||||
createSkill: (params: CreateSkillArgs & { agentId: string }) => Promise<SkillDetail>;
|
||||
getSkill: (params: GetSkillArgs & { agentId: string }) => Promise<SkillDetail | undefined>;
|
||||
listSkills: (params: ListSkillsArgs & { agentId: string }) => Promise<SkillSummary[]>;
|
||||
renameSkill: (params: RenameSkillArgs & { agentId: string }) => Promise<SkillDetail | undefined>;
|
||||
replaceSkillIndex: (
|
||||
params: ReplaceSkillIndexArgs & { agentId: string },
|
||||
) => Promise<SkillDetail | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the hidden skill-maintainer builtin APIs.
|
||||
*
|
||||
* Use when:
|
||||
* - Server runtime registry needs a package-level wrapper around skill-management service calls.
|
||||
* - Agent Signal workers need stable tool outputs with agent document binding ids.
|
||||
*
|
||||
* Expects:
|
||||
* - The tool execution context provides `agentId`.
|
||||
* - The injected service owns bundle/index validation and persistence.
|
||||
*
|
||||
* Returns:
|
||||
* - Builtin runtime outputs whose `state` preserves `agentDocumentId` and backing `documentId`.
|
||||
*/
|
||||
export class SkillMaintainerExecutionRuntime {
|
||||
constructor(private service: SkillMaintainerRuntimeService) {}
|
||||
|
||||
private resolveAgentId(context?: SkillMaintainerOperationContext) {
|
||||
if (!context?.agentId) return;
|
||||
return context.agentId;
|
||||
}
|
||||
|
||||
private missingAgentIdResult(action: string): BuiltinServerRuntimeOutput {
|
||||
return {
|
||||
content: `Cannot ${action} managed skills without agentId context.`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
private success(content: string, state?: Record<string, unknown>): BuiltinServerRuntimeOutput {
|
||||
return {
|
||||
content,
|
||||
...(state && { state }),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
async listSkills(
|
||||
args: ListSkillsArgs,
|
||||
context?: SkillMaintainerOperationContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
const agentId = this.resolveAgentId(context);
|
||||
if (!agentId) return this.missingAgentIdResult('list');
|
||||
|
||||
const skills = await this.service.listSkills({ ...args, agentId });
|
||||
|
||||
return this.success(JSON.stringify(skills), { skills });
|
||||
}
|
||||
|
||||
async getSkill(
|
||||
args: GetSkillArgs,
|
||||
context?: SkillMaintainerOperationContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
const agentId = this.resolveAgentId(context);
|
||||
if (!agentId) return this.missingAgentIdResult('read');
|
||||
|
||||
const skill = await this.service.getSkill({ ...args, agentId });
|
||||
if (!skill) return { content: 'Managed skill not found.', success: false };
|
||||
|
||||
return this.success(JSON.stringify(skill), { skill });
|
||||
}
|
||||
|
||||
async createSkill(
|
||||
args: CreateSkillArgs,
|
||||
context?: SkillMaintainerOperationContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
const agentId = this.resolveAgentId(context);
|
||||
if (!agentId) return this.missingAgentIdResult('create');
|
||||
|
||||
const skill = await this.service.createSkill({ ...args, agentId });
|
||||
|
||||
return this.success(
|
||||
`Created managed skill "${skill.name}" (${skill.bundle.agentDocumentId}).`,
|
||||
{
|
||||
skill,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async replaceSkillIndex(
|
||||
args: ReplaceSkillIndexArgs,
|
||||
context?: SkillMaintainerOperationContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
const agentId = this.resolveAgentId(context);
|
||||
if (!agentId) return this.missingAgentIdResult('replace');
|
||||
|
||||
const skill = await this.service.replaceSkillIndex({ ...args, agentId });
|
||||
if (!skill) return { content: 'Managed skill not found.', success: false };
|
||||
|
||||
return this.success(`Replaced managed skill "${skill.name}" index.`, { skill });
|
||||
}
|
||||
|
||||
async renameSkill(
|
||||
args: RenameSkillArgs,
|
||||
context?: SkillMaintainerOperationContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
const agentId = this.resolveAgentId(context);
|
||||
if (!agentId) return this.missingAgentIdResult('rename');
|
||||
|
||||
const skill = await this.service.renameSkill({ ...args, agentId });
|
||||
if (!skill) return { content: 'Managed skill not found.', success: false };
|
||||
|
||||
return this.success(`Renamed managed skill "${skill.name}".`, { skill });
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,13 @@
|
||||
export type { SkillMaintainerRuntimeService } from './ExecutionRuntime';
|
||||
export { SkillMaintainerExecutionRuntime } from './ExecutionRuntime';
|
||||
export { SkillMaintainerManifest } from './manifest';
|
||||
export { SkillMaintainerApiName, SkillMaintainerIdentifier } from './types';
|
||||
export {
|
||||
type CreateSkillArgs,
|
||||
type GetSkillArgs,
|
||||
type ListSkillsArgs,
|
||||
type RenameSkillArgs,
|
||||
type ReplaceSkillIndexArgs,
|
||||
SkillMaintainerApiName,
|
||||
SkillMaintainerIdentifier,
|
||||
type SkillTargetArgs,
|
||||
} from './types';
|
||||
|
||||
@@ -5,29 +5,32 @@ import { SkillMaintainerManifest } from './manifest';
|
||||
describe('SkillMaintainerManifest', () => {
|
||||
/**
|
||||
* @example
|
||||
* The hidden skill maintainer exposes the complete v1.2 tool surface.
|
||||
* The hidden skill maintainer exposes only the document-backed v1 tool surface.
|
||||
*/
|
||||
it('exposes only the v1.2 skill-management tools', () => {
|
||||
it('exposes only the v1 skill-management tools', () => {
|
||||
expect(SkillMaintainerManifest.api.map((item) => item.name).sort()).toEqual([
|
||||
'consolidate',
|
||||
'readSkillFile',
|
||||
'refine',
|
||||
'removeSkillFile',
|
||||
'updateSkill',
|
||||
'writeSkillFile',
|
||||
'createSkill',
|
||||
'getSkill',
|
||||
'listSkills',
|
||||
'renameSkill',
|
||||
'replaceSkillIndex',
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Lifecycle and broad exploration APIs stay out of the active manifest.
|
||||
* Path, resource, delete, and broad exploration APIs stay out of the active manifest.
|
||||
*/
|
||||
it('does not expose exploratory or lifecycle mutation tools', () => {
|
||||
it('does not expose forbidden v1 APIs', () => {
|
||||
const names = SkillMaintainerManifest.api.map((item) => item.name);
|
||||
|
||||
expect(names).not.toContain('createSkill');
|
||||
expect(names).not.toContain('consolidate');
|
||||
expect(names).not.toContain('deleteSkill');
|
||||
expect(names).not.toContain('forkSkill');
|
||||
expect(names).not.toContain('listSkillResources');
|
||||
expect(names).not.toContain('mergeSkill');
|
||||
expect(names).not.toContain('readSkillFile');
|
||||
expect(names).not.toContain('removeSkillFile');
|
||||
expect(names).not.toContain('writeSkillFile');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,28 @@ import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { SkillMaintainerApiName, SkillMaintainerIdentifier } from './types';
|
||||
|
||||
const skillRefProperties = {
|
||||
skillRef: { type: 'string' },
|
||||
const skillTargetProperties = {
|
||||
agentDocumentId: {
|
||||
description: 'Managed skill bundle id from agent_documents.id.',
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
description: 'Stable managed skill bundle name.',
|
||||
type: 'string',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const filePathProperties = {
|
||||
path: { type: 'string' },
|
||||
...skillRefProperties,
|
||||
const skillBodyMarkdownProperty = {
|
||||
description: 'Markdown body for SKILL.md. Do not include YAML frontmatter.',
|
||||
type: 'string',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* System-only builtin manifest for automatic skill maintenance.
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal delegates refinement or consolidation to maintainer tools
|
||||
* - A hidden system surface needs merge orchestration APIs
|
||||
* - Agent Signal delegates skill creation, refinement, or rename work to a hidden worker.
|
||||
* - A hidden system surface needs document-backed skill-management APIs.
|
||||
*
|
||||
* Expects:
|
||||
* - Calls are made by trusted orchestration code
|
||||
@@ -27,73 +34,69 @@ const filePathProperties = {
|
||||
export const SkillMaintainerManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
{
|
||||
description: 'Read one package-relative file from a managed skill.',
|
||||
name: SkillMaintainerApiName.readSkillFile,
|
||||
description: 'List managed skills for the current agent.',
|
||||
name: SkillMaintainerApiName.listSkills,
|
||||
parameters: {
|
||||
properties: filePathProperties,
|
||||
required: ['skillRef', 'path'],
|
||||
properties: {},
|
||||
required: [],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Update one existing package-relative file in a managed skill.',
|
||||
name: SkillMaintainerApiName.updateSkill,
|
||||
description: 'Read one managed skill bundle and its SKILL.md index.',
|
||||
name: SkillMaintainerApiName.getSkill,
|
||||
parameters: {
|
||||
properties: {
|
||||
content: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
...filePathProperties,
|
||||
includeContent: { type: 'boolean' },
|
||||
...skillTargetProperties,
|
||||
},
|
||||
required: ['skillRef', 'path', 'content', 'reason'],
|
||||
required: [],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Write one package-relative file in a managed skill.',
|
||||
name: SkillMaintainerApiName.writeSkillFile,
|
||||
description: 'Create a managed skill bundle and SKILL.md index.',
|
||||
name: SkillMaintainerApiName.createSkill,
|
||||
parameters: {
|
||||
properties: {
|
||||
content: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
...filePathProperties,
|
||||
bodyMarkdown: skillBodyMarkdownProperty,
|
||||
description: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
sourceAgentDocumentId: {
|
||||
description: 'Existing hinted agent document id from agent_documents.id.',
|
||||
type: 'string',
|
||||
},
|
||||
title: { type: 'string' },
|
||||
},
|
||||
required: ['skillRef', 'path', 'content', 'reason'],
|
||||
required: ['name', 'title', 'description', 'bodyMarkdown'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Remove one package-relative file from a managed skill.',
|
||||
name: SkillMaintainerApiName.removeSkillFile,
|
||||
description: 'Replace the SKILL.md index content for a managed skill.',
|
||||
name: SkillMaintainerApiName.replaceSkillIndex,
|
||||
parameters: {
|
||||
properties: {
|
||||
bodyMarkdown: skillBodyMarkdownProperty,
|
||||
description: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
...filePathProperties,
|
||||
...skillTargetProperties,
|
||||
},
|
||||
required: ['skillRef', 'path', 'reason'],
|
||||
required: ['bodyMarkdown'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Run single-skill refinement for a target skill reference.',
|
||||
name: SkillMaintainerApiName.refine,
|
||||
description: 'Rename a managed skill bundle and synchronize SKILL.md frontmatter.',
|
||||
name: SkillMaintainerApiName.renameSkill,
|
||||
parameters: {
|
||||
properties: {
|
||||
newName: { type: 'string' },
|
||||
newTitle: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
skillRef: { type: 'string' },
|
||||
...skillTargetProperties,
|
||||
},
|
||||
required: ['skillRef', 'reason'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Find and reconcile overlapping skills.',
|
||||
name: SkillMaintainerApiName.consolidate,
|
||||
parameters: {
|
||||
properties: {
|
||||
reason: { type: 'string' },
|
||||
sourceSkillIds: { items: { type: 'string' }, type: 'array' },
|
||||
},
|
||||
required: ['sourceSkillIds'],
|
||||
required: [],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
@@ -101,9 +104,10 @@ export const SkillMaintainerManifest: BuiltinToolManifest = {
|
||||
identifier: SkillMaintainerIdentifier,
|
||||
meta: {
|
||||
description:
|
||||
'Run hidden Agent Signal maintenance actions for skill refinement and consolidation.',
|
||||
'Run hidden Agent Signal maintenance actions for document-backed skill management.',
|
||||
title: 'Skill Maintainer',
|
||||
},
|
||||
systemRole: 'Maintain skills through Agent Signal. This tool is system-only.',
|
||||
systemRole:
|
||||
'Maintain skills through Agent Signal using only list/get/create/replace/rename operations. This tool is system-only and cannot delete skills or manage skill resources.',
|
||||
type: 'builtin',
|
||||
};
|
||||
|
||||
@@ -3,10 +3,60 @@ export const SkillMaintainerIdentifier = 'lobe-skill-maintainer';
|
||||
|
||||
/** API names exposed by the system-only skill maintainer builtin tool. */
|
||||
export const SkillMaintainerApiName = {
|
||||
consolidate: 'consolidate',
|
||||
readSkillFile: 'readSkillFile',
|
||||
refine: 'refine',
|
||||
removeSkillFile: 'removeSkillFile',
|
||||
updateSkill: 'updateSkill',
|
||||
writeSkillFile: 'writeSkillFile',
|
||||
createSkill: 'createSkill',
|
||||
getSkill: 'getSkill',
|
||||
listSkills: 'listSkills',
|
||||
renameSkill: 'renameSkill',
|
||||
replaceSkillIndex: 'replaceSkillIndex',
|
||||
} as const;
|
||||
|
||||
/** Common selector for a managed skill bundle. */
|
||||
export interface SkillTargetArgs {
|
||||
/** Agent document binding id from `agent_documents.id`. */
|
||||
agentDocumentId?: string;
|
||||
/** Stable bundle filename. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Args for listing managed skills. */
|
||||
export interface ListSkillsArgs {}
|
||||
|
||||
/** Args for reading one managed skill. */
|
||||
export interface GetSkillArgs extends SkillTargetArgs {
|
||||
/** Include raw `SKILL.md` content in the result. */
|
||||
includeContent?: boolean;
|
||||
}
|
||||
|
||||
/** Args for creating a managed skill. */
|
||||
export interface CreateSkillArgs {
|
||||
/** Markdown body only; YAML frontmatter is rendered from structured fields. */
|
||||
bodyMarkdown: string;
|
||||
/** Canonical skill description. */
|
||||
description: string;
|
||||
/** Stable bundle filename. */
|
||||
name: string;
|
||||
/** Existing hinted agent document binding id to convert into the index. */
|
||||
sourceAgentDocumentId?: string;
|
||||
/** Human-readable bundle title. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Args for replacing the managed skill index document. */
|
||||
export interface ReplaceSkillIndexArgs extends SkillTargetArgs {
|
||||
/** Replacement Markdown body only; YAML frontmatter is rendered from structured fields. */
|
||||
bodyMarkdown: string;
|
||||
/** Optional canonical description override. */
|
||||
description?: string;
|
||||
/** Reason for later audit/history surfaces. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Args for renaming a managed skill bundle. */
|
||||
export interface RenameSkillArgs extends SkillTargetArgs {
|
||||
/** New stable bundle filename. */
|
||||
newName?: string;
|
||||
/** New human-readable bundle title. */
|
||||
newTitle?: string;
|
||||
/** Reason for later audit/history surfaces. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -179,6 +179,29 @@ describe('DocumentModel', () => {
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should exclude agent-owned documents unless sourceTypes explicitly requests them', async () => {
|
||||
await createTestDocument(documentModel, fileModel, 'Visible document');
|
||||
await documentModel.create({
|
||||
content: 'Agent document',
|
||||
fileType: 'agent/document',
|
||||
filename: 'agent-document',
|
||||
source: 'agent-document://agent-1/agent-document',
|
||||
sourceType: 'agent',
|
||||
totalCharCount: 14,
|
||||
totalLineCount: 1,
|
||||
});
|
||||
|
||||
const defaultResult = await documentModel.query();
|
||||
|
||||
expect(defaultResult.items).toHaveLength(1);
|
||||
expect(defaultResult.items[0].sourceType).not.toBe('agent');
|
||||
|
||||
const agentResult = await documentModel.query({ sourceTypes: ['agent'] });
|
||||
|
||||
expect(agentResult.items).toHaveLength(1);
|
||||
expect(agentResult.items[0].sourceType).toBe('agent');
|
||||
});
|
||||
|
||||
it('should only return documents for the current user', async () => {
|
||||
await createTestDocument(documentModel, fileModel, 'User 1 document');
|
||||
await createTestDocument(documentModel2, fileModel2, 'User 2 document');
|
||||
|
||||
@@ -257,6 +257,28 @@ describe('RecentModel', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes agent-owned document rows', async () => {
|
||||
await serverDB.insert(documents).values([
|
||||
{
|
||||
id: 'doc-agent',
|
||||
userId,
|
||||
sourceType: 'agent',
|
||||
updatedAt: minutesAgo(1),
|
||||
...baseDocFields,
|
||||
},
|
||||
{
|
||||
id: 'doc-agent-signal',
|
||||
userId,
|
||||
sourceType: 'agent-signal',
|
||||
updatedAt: now(),
|
||||
...baseDocFields,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await recentModel.queryRecent();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes documents inside a knowledge base', async () => {
|
||||
await serverDB.insert(knowledgeBases).values({ id: 'kb-1', userId, name: 'kb' });
|
||||
await serverDB.insert(documents).values({
|
||||
|
||||
@@ -159,6 +159,65 @@ describe('AgentDocumentModel', () => {
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates ordinary agent documents with agent source attribution by default', async () => {
|
||||
const created = await agentDocumentModel.create(agentId, 'brief', 'content');
|
||||
|
||||
expect(created.sourceType).toBe('agent');
|
||||
expect(created.source).toBe(`agent-document://${agentId}/brief`);
|
||||
});
|
||||
|
||||
it('allows trusted callers to set document source attribution', async () => {
|
||||
const created = await agentDocumentModel.create(agentId, 'skill-a', 'content', {
|
||||
source: 'agent-signal:skill-management',
|
||||
sourceType: 'agent-signal',
|
||||
});
|
||||
|
||||
expect(created.sourceType).toBe('agent-signal');
|
||||
expect(created.source).toBe('agent-signal:skill-management');
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Higher-level services can compose multiple agent document writes in one transaction.
|
||||
*/
|
||||
it('rolls back createWithTx when the caller transaction fails', async () => {
|
||||
let createdAgentDocumentId: string | undefined;
|
||||
let createdDocumentId: string | undefined;
|
||||
|
||||
await expect(
|
||||
serverDB.transaction(async (trx) => {
|
||||
const created = await agentDocumentModel.createWithTx(
|
||||
trx,
|
||||
agentId,
|
||||
'rollback-note',
|
||||
'content',
|
||||
);
|
||||
createdAgentDocumentId = created.id;
|
||||
createdDocumentId = created.documentId;
|
||||
|
||||
throw new Error('Intentional rollback');
|
||||
}),
|
||||
).rejects.toThrow('Intentional rollback');
|
||||
|
||||
if (createdAgentDocumentId) {
|
||||
const [binding] = await serverDB
|
||||
.select()
|
||||
.from(agentDocuments)
|
||||
.where(eq(agentDocuments.id, createdAgentDocumentId));
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
}
|
||||
|
||||
if (createdDocumentId) {
|
||||
const [doc] = await serverDB
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, createdDocumentId));
|
||||
|
||||
expect(doc).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should create an agent document with normalized policy and linked document row', async () => {
|
||||
const result = await agentDocumentModel.create(agentId, 'identity.md', 'line1\nline2', {
|
||||
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
|
||||
@@ -469,6 +528,152 @@ describe('AgentDocumentModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertAgentDocumentToSkillIndex and updateDocumentIdentity', () => {
|
||||
it('converts an ordinary agent document binding into a skill index while preserving ids', async () => {
|
||||
const source = await agentDocumentModel.create(agentId, 'workflow-note', '# Workflow', {
|
||||
metadata: { agentSignal: { hintIsSkill: true } },
|
||||
});
|
||||
const bundle = await agentDocumentModel.create(agentId, 'workflow-note', '', {
|
||||
fileType: 'skills/bundle',
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: 'agent-signal:skill-management',
|
||||
sourceType: 'agent-signal',
|
||||
});
|
||||
|
||||
const converted = await agentDocumentModel.convertAgentDocumentToSkillIndex({
|
||||
agentDocumentId: source.id,
|
||||
content: '---\nname: workflow-note\ndescription: Workflow note\n---\n# Workflow',
|
||||
editorData: { root: { children: [], type: 'root' } },
|
||||
filename: 'workflow-note',
|
||||
metadata: {
|
||||
agentSignal: { hintIsSkill: true },
|
||||
skill: { frontmatter: { description: 'Workflow note', name: 'workflow-note' } },
|
||||
},
|
||||
parentId: bundle.documentId,
|
||||
source: 'agent-signal:skill-management',
|
||||
sourceType: 'agent-signal',
|
||||
title: 'Workflow Note',
|
||||
});
|
||||
|
||||
expect(converted?.id).toBe(source.id);
|
||||
expect(converted?.documentId).toBe(source.documentId);
|
||||
expect(converted?.fileType).toBe('skills/index');
|
||||
expect(converted?.filename).toBe('workflow-note');
|
||||
expect(converted?.parentId).toBe(bundle.documentId);
|
||||
expect(converted?.policyLoad).toBe(PolicyLoad.DISABLED);
|
||||
expect(converted?.sourceType).toBe('agent-signal');
|
||||
expect(converted?.source).toBe('agent-signal:skill-management');
|
||||
expect(converted?.templateId).toBe('agent-skill');
|
||||
expect(converted?.title).toBe('Workflow Note');
|
||||
expect(converted?.metadata).toMatchObject({
|
||||
agentSignal: { hintIsSkill: true },
|
||||
skill: { frontmatter: { description: 'Workflow note', name: 'workflow-note' } },
|
||||
});
|
||||
|
||||
const [doc] = await serverDB
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, source.documentId));
|
||||
|
||||
expect(doc?.description).toBe('Workflow note');
|
||||
expect(doc?.totalCharCount).toBe(
|
||||
'---\nname: workflow-note\ndescription: Workflow note\n---\n# Workflow'.length,
|
||||
);
|
||||
expect(doc?.totalLineCount).toBe(5);
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Skill creation can convert an existing source document and still roll back as one aggregate.
|
||||
*/
|
||||
it('rolls back convertAgentDocumentToSkillIndexWithTx when the caller transaction fails', async () => {
|
||||
const source = await agentDocumentModel.create(agentId, 'workflow-note', '# Workflow', {
|
||||
metadata: { agentSignal: { hintIsSkill: true } },
|
||||
});
|
||||
const bundle = await agentDocumentModel.create(agentId, 'workflow-note', '', {
|
||||
fileType: 'skills/bundle',
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: 'agent-signal:skill-management',
|
||||
sourceType: 'agent-signal',
|
||||
});
|
||||
|
||||
await expect(
|
||||
serverDB.transaction(async (trx) => {
|
||||
await agentDocumentModel.convertAgentDocumentToSkillIndexWithTx(trx, {
|
||||
agentDocumentId: source.id,
|
||||
content: '---\nname: workflow-note\ndescription: Workflow note\n---\n# Workflow',
|
||||
filename: 'SKILL.md',
|
||||
metadata: {
|
||||
agentSignal: { hintIsSkill: true },
|
||||
skill: { frontmatter: { description: 'Workflow note', name: 'workflow-note' } },
|
||||
},
|
||||
parentId: bundle.documentId,
|
||||
source: 'agent-signal:skill-management',
|
||||
sourceType: 'agent-signal',
|
||||
title: 'SKILL.md',
|
||||
});
|
||||
|
||||
throw new Error('Intentional rollback');
|
||||
}),
|
||||
).rejects.toThrow('Intentional rollback');
|
||||
|
||||
const unchanged = await agentDocumentModel.findById(source.id);
|
||||
|
||||
expect(unchanged).toMatchObject({
|
||||
documentId: source.documentId,
|
||||
fileType: 'agent/document',
|
||||
filename: 'workflow-note',
|
||||
parentId: null,
|
||||
policyLoad: PolicyLoad.PROGRESSIVE,
|
||||
sourceType: 'agent',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates backing document identity fields without changing the agent document binding', async () => {
|
||||
const folder = await agentDocumentModel.create(agentId, 'skills', '', {
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
title: 'skills',
|
||||
});
|
||||
const created = await agentDocumentModel.create(agentId, 'old-name', 'content');
|
||||
|
||||
const updated = await agentDocumentModel.updateDocumentIdentity(created.id, {
|
||||
filename: 'new-name',
|
||||
metadata: { skill: { frontmatter: { description: 'New', name: 'new-name' } } },
|
||||
parentId: folder.documentId,
|
||||
title: 'New Name',
|
||||
});
|
||||
|
||||
expect(updated?.id).toBe(created.id);
|
||||
expect(updated?.documentId).toBe(created.documentId);
|
||||
expect(updated?.filename).toBe('new-name');
|
||||
expect(updated?.parentId).toBe(folder.documentId);
|
||||
expect(updated?.title).toBe('New Name');
|
||||
expect(updated?.metadata).toMatchObject({
|
||||
skill: { frontmatter: { description: 'New', name: 'new-name' } },
|
||||
});
|
||||
|
||||
const [doc] = await serverDB
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, created.documentId));
|
||||
|
||||
expect(doc?.description).toBe('New');
|
||||
});
|
||||
|
||||
it('returns the existing binding when document identity update has no fields', async () => {
|
||||
const created = await agentDocumentModel.create(agentId, 'unchanged', 'content');
|
||||
|
||||
const updated = await agentDocumentModel.updateDocumentIdentity(created.id, {});
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
documentId: created.documentId,
|
||||
filename: 'unchanged',
|
||||
id: created.id,
|
||||
title: 'unchanged',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByAgent and findByTemplate', () => {
|
||||
it('should return matched docs with parsed loadRules', async () => {
|
||||
await agentDocumentModel.create(agentId, 'a.md', 'A', {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { and, asc, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm';
|
||||
|
||||
import type { DocumentItem, NewAgentDocument, NewDocument } from '../../schemas';
|
||||
import { agentDocuments, documents } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import type { LobeChatDatabase, Transaction } from '../../type';
|
||||
import { buildDocumentFilename } from './filename';
|
||||
import {
|
||||
composeToolPolicyUpdate,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type {
|
||||
AgentDocument,
|
||||
AgentDocumentPolicy,
|
||||
AgentDocumentSourceType,
|
||||
AgentDocumentWithRules,
|
||||
DocumentLoadRules,
|
||||
ToolUpdateLoadRule,
|
||||
@@ -36,6 +37,35 @@ interface AgentDocumentQueryOptions {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface AgentDocumentCreateParams {
|
||||
createdAt?: Date;
|
||||
editorData?: Record<string, any>;
|
||||
fileType?: string;
|
||||
loadPosition?: DocumentLoadPosition;
|
||||
loadRules?: DocumentLoadRules;
|
||||
metadata?: Record<string, any>;
|
||||
parentId?: string | null;
|
||||
policy?: AgentDocumentPolicy;
|
||||
policyLoad?: PolicyLoad;
|
||||
source?: string;
|
||||
sourceType?: AgentDocumentSourceType;
|
||||
templateId?: string;
|
||||
title?: string;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
interface ConvertAgentDocumentToSkillIndexParams {
|
||||
agentDocumentId: string;
|
||||
content: string;
|
||||
editorData?: Record<string, unknown>;
|
||||
filename: string;
|
||||
metadata: Record<string, unknown>;
|
||||
parentId: string;
|
||||
source: string;
|
||||
sourceType: AgentDocumentSourceType;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export class AgentDocumentModel {
|
||||
private userId: string;
|
||||
private db: LobeChatDatabase;
|
||||
@@ -54,6 +84,21 @@ export class AgentDocumentModel {
|
||||
};
|
||||
}
|
||||
|
||||
private getMetadataDescription(metadata?: Record<string, unknown> | null): string | undefined {
|
||||
if (!metadata) return undefined;
|
||||
|
||||
if (typeof metadata.description === 'string') return metadata.description;
|
||||
|
||||
const skill = metadata.skill;
|
||||
if (!skill || typeof skill !== 'object') return undefined;
|
||||
|
||||
const frontmatter = (skill as Record<string, unknown>).frontmatter;
|
||||
if (!frontmatter || typeof frontmatter !== 'object') return undefined;
|
||||
|
||||
const description = (frontmatter as Record<string, unknown>).description;
|
||||
return typeof description === 'string' ? description : undefined;
|
||||
}
|
||||
|
||||
private toAgentDocument(
|
||||
settings: typeof agentDocuments.$inferSelect,
|
||||
doc: DocumentItem,
|
||||
@@ -214,20 +259,31 @@ export class AgentDocumentModel {
|
||||
agentId: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
params?: {
|
||||
createdAt?: Date;
|
||||
editorData?: Record<string, any>;
|
||||
fileType?: string;
|
||||
loadPosition?: DocumentLoadPosition;
|
||||
loadRules?: DocumentLoadRules;
|
||||
metadata?: Record<string, any>;
|
||||
parentId?: string | null;
|
||||
policy?: AgentDocumentPolicy;
|
||||
policyLoad?: PolicyLoad;
|
||||
templateId?: string;
|
||||
title?: string;
|
||||
updatedAt?: Date;
|
||||
},
|
||||
params?: AgentDocumentCreateParams,
|
||||
): Promise<AgentDocument> {
|
||||
return this.db.transaction((trx) => this.createWithTx(trx, agentId, filename, content, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a document row and links it to an agent inside a caller-owned transaction.
|
||||
*
|
||||
* Use when:
|
||||
* - A higher-level aggregate must create multiple agent documents atomically.
|
||||
* - Callers already run `db.transaction` and need to avoid nested transactions.
|
||||
*
|
||||
* Expects:
|
||||
* - `trx` is the active transaction for every write in the aggregate.
|
||||
* - `filename` is a single VFS segment supplied by the caller.
|
||||
*
|
||||
* Returns:
|
||||
* - The created agent document with joined document content and metadata.
|
||||
*/
|
||||
async createWithTx(
|
||||
trx: Transaction,
|
||||
agentId: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
params?: AgentDocumentCreateParams,
|
||||
): Promise<AgentDocument> {
|
||||
const {
|
||||
createdAt,
|
||||
@@ -239,6 +295,8 @@ export class AgentDocumentModel {
|
||||
parentId,
|
||||
policy,
|
||||
policyLoad,
|
||||
source,
|
||||
sourceType = 'agent',
|
||||
templateId,
|
||||
title: providedTitle,
|
||||
updatedAt,
|
||||
@@ -248,58 +306,168 @@ export class AgentDocumentModel {
|
||||
const stats = this.getDocumentStats(content);
|
||||
const normalizedPolicy = normalizePolicy(loadPosition, loadRules, policy);
|
||||
|
||||
return this.db.transaction(async (trx) => {
|
||||
const documentPayload: NewDocument = {
|
||||
content,
|
||||
createdAt,
|
||||
description: metadata?.description,
|
||||
editorData,
|
||||
fileType,
|
||||
filename,
|
||||
parentId,
|
||||
metadata,
|
||||
source: `agent-document://${agentId}/${encodeURIComponent(filename)}`,
|
||||
sourceType: 'file',
|
||||
title,
|
||||
const documentPayload: NewDocument = {
|
||||
content,
|
||||
createdAt,
|
||||
description: this.getMetadataDescription(metadata),
|
||||
editorData,
|
||||
fileType,
|
||||
filename,
|
||||
parentId,
|
||||
metadata,
|
||||
source: source ?? `agent-document://${agentId}/${encodeURIComponent(filename)}`,
|
||||
sourceType,
|
||||
title,
|
||||
totalCharCount: stats.totalCharCount,
|
||||
totalLineCount: stats.totalLineCount,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
userId: this.userId,
|
||||
};
|
||||
|
||||
const [insertedDocument] = await trx.insert(documents).values(documentPayload).returning();
|
||||
|
||||
const newDoc: NewAgentDocument = {
|
||||
accessPublic: 0,
|
||||
accessSelf:
|
||||
AgentAccess.EXECUTE |
|
||||
AgentAccess.LIST |
|
||||
AgentAccess.READ |
|
||||
AgentAccess.WRITE |
|
||||
AgentAccess.DELETE,
|
||||
accessShared: 0,
|
||||
agentId,
|
||||
createdAt,
|
||||
policyLoad: policyLoad ?? PolicyLoad.PROGRESSIVE,
|
||||
deleteReason: null,
|
||||
deletedAt: null,
|
||||
deletedByAgentId: null,
|
||||
deletedByUserId: null,
|
||||
documentId: insertedDocument!.id,
|
||||
policy: normalizedPolicy,
|
||||
policyLoadFormat: normalizedPolicy.context?.policyLoadFormat || DocumentLoadFormat.RAW,
|
||||
policyLoadPosition:
|
||||
normalizedPolicy.context?.position || DocumentLoadPosition.BEFORE_FIRST_USER,
|
||||
policyLoadRule: normalizedPolicy.context?.rule || DocumentLoadRule.ALWAYS,
|
||||
templateId,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
userId: this.userId,
|
||||
};
|
||||
|
||||
const [settings] = await trx.insert(agentDocuments).values(newDoc).returning();
|
||||
|
||||
return this.toAgentDocument(settings!, insertedDocument!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an existing ordinary agent document binding into a managed skill index.
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal promoted an already-created agent document into skill management.
|
||||
* - The caller must preserve both the agent document id and backing document id.
|
||||
*
|
||||
* Expects:
|
||||
* - `agentDocumentId` is a live binding owned by the current user.
|
||||
* - `parentId` points to the managed skill bundle document row.
|
||||
*
|
||||
* Returns:
|
||||
* - The same agent document binding after document identity and load metadata are updated.
|
||||
*
|
||||
*/
|
||||
async convertAgentDocumentToSkillIndex(
|
||||
params: ConvertAgentDocumentToSkillIndexParams,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
return this.db.transaction((trx) => this.convertAgentDocumentToSkillIndexWithTx(trx, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a live agent document binding into a managed skill index inside a transaction.
|
||||
*
|
||||
* Use when:
|
||||
* - A higher-level skill aggregate also creates the owning bundle in the same transaction.
|
||||
* - The caller must preserve both `agent_documents.id` and `documents.id`.
|
||||
*
|
||||
* Expects:
|
||||
* - `trx` is the active transaction for the whole skill creation operation.
|
||||
* - `parentId` points to the managed skill bundle document row inside the same transaction.
|
||||
*
|
||||
* Returns:
|
||||
* - The same agent document binding after document identity and load metadata are updated.
|
||||
*/
|
||||
async convertAgentDocumentToSkillIndexWithTx(
|
||||
trx: Transaction,
|
||||
params: ConvertAgentDocumentToSkillIndexParams,
|
||||
): Promise<AgentDocument | undefined> {
|
||||
const [existingResult] = await trx
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.id, params.agentDocumentId),
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingResult) return undefined;
|
||||
|
||||
const existing = this.toAgentDocument(existingResult.settings, existingResult.doc);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const stats = this.getDocumentStats(params.content);
|
||||
const updatedAt = new Date();
|
||||
|
||||
await trx
|
||||
.update(documents)
|
||||
.set({
|
||||
content: params.content,
|
||||
description: this.getMetadataDescription(params.metadata),
|
||||
...(params.editorData !== undefined && { editorData: params.editorData }),
|
||||
filename: params.filename,
|
||||
fileType: 'skills/index',
|
||||
metadata: params.metadata,
|
||||
parentId: params.parentId,
|
||||
source: params.source,
|
||||
sourceType: params.sourceType,
|
||||
title: params.title,
|
||||
totalCharCount: stats.totalCharCount,
|
||||
totalLineCount: stats.totalLineCount,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
userId: this.userId,
|
||||
};
|
||||
updatedAt,
|
||||
})
|
||||
.where(and(eq(documents.id, existing.documentId), eq(documents.userId, this.userId)));
|
||||
|
||||
const [insertedDocument] = await trx.insert(documents).values(documentPayload).returning();
|
||||
await trx
|
||||
.update(agentDocuments)
|
||||
.set({
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
templateId: 'agent-skill',
|
||||
updatedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.id, params.agentDocumentId),
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
const newDoc: NewAgentDocument = {
|
||||
accessPublic: 0,
|
||||
accessSelf:
|
||||
AgentAccess.EXECUTE |
|
||||
AgentAccess.LIST |
|
||||
AgentAccess.READ |
|
||||
AgentAccess.WRITE |
|
||||
AgentAccess.DELETE,
|
||||
accessShared: 0,
|
||||
agentId,
|
||||
createdAt,
|
||||
policyLoad: policyLoad ?? PolicyLoad.PROGRESSIVE,
|
||||
deleteReason: null,
|
||||
deletedAt: null,
|
||||
deletedByAgentId: null,
|
||||
deletedByUserId: null,
|
||||
documentId: insertedDocument!.id,
|
||||
policy: normalizedPolicy,
|
||||
policyLoadFormat: normalizedPolicy.context?.policyLoadFormat || DocumentLoadFormat.RAW,
|
||||
policyLoadPosition:
|
||||
normalizedPolicy.context?.position || DocumentLoadPosition.BEFORE_FIRST_USER,
|
||||
policyLoadRule: normalizedPolicy.context?.rule || DocumentLoadRule.ALWAYS,
|
||||
templateId,
|
||||
updatedAt: updatedAt ?? createdAt,
|
||||
userId: this.userId,
|
||||
};
|
||||
const [updatedResult] = await trx
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(agentDocuments.id, params.agentDocumentId),
|
||||
eq(agentDocuments.userId, this.userId),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const [settings] = await trx.insert(agentDocuments).values(newDoc).returning();
|
||||
|
||||
return this.toAgentDocument(settings!, insertedDocument!);
|
||||
});
|
||||
return updatedResult
|
||||
? this.toAgentDocument(updatedResult.settings, updatedResult.doc)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -367,7 +535,7 @@ export class AgentDocumentModel {
|
||||
|
||||
if (metadata !== undefined) {
|
||||
documentUpdate.metadata = metadata;
|
||||
documentUpdate.description = metadata?.description;
|
||||
documentUpdate.description = this.getMetadataDescription(metadata);
|
||||
}
|
||||
|
||||
await trx
|
||||
@@ -383,6 +551,58 @@ export class AgentDocumentModel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates backing document identity fields without changing ids or load policy.
|
||||
*
|
||||
* Use when:
|
||||
* - Managed skill services need to rename or reparent a document row.
|
||||
* - Callers must preserve agent document id and backing document id.
|
||||
*
|
||||
* Expects:
|
||||
* - `agentDocumentId` is the agent document binding id, not the backing document row id.
|
||||
* - Omitted fields are left untouched.
|
||||
*
|
||||
* Returns:
|
||||
* - The same agent document binding after identity fields are updated.
|
||||
*
|
||||
*/
|
||||
async updateDocumentIdentity(
|
||||
agentDocumentId: string,
|
||||
params: {
|
||||
filename?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
parentId?: string | null;
|
||||
title?: string;
|
||||
},
|
||||
): Promise<AgentDocument | undefined> {
|
||||
const existing = await this.findById(agentDocumentId);
|
||||
if (!existing) return undefined;
|
||||
|
||||
if (
|
||||
params.filename === undefined &&
|
||||
params.metadata === undefined &&
|
||||
params.parentId === undefined &&
|
||||
params.title === undefined
|
||||
) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.update(documents)
|
||||
.set({
|
||||
...(params.filename !== undefined && { filename: params.filename }),
|
||||
...(params.metadata !== undefined && {
|
||||
description: this.getMetadataDescription(params.metadata),
|
||||
metadata: params.metadata,
|
||||
}),
|
||||
...(params.parentId !== undefined && { parentId: params.parentId }),
|
||||
...(params.title !== undefined && { title: params.title }),
|
||||
})
|
||||
.where(and(eq(documents.id, existing.documentId), eq(documents.userId, this.userId)));
|
||||
|
||||
return this.findById(agentDocumentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an agent document by updating the backing document filename and title.
|
||||
*
|
||||
|
||||
@@ -22,7 +22,7 @@ export {
|
||||
// Type-only exports (interfaces)
|
||||
export type { AgentDocumentPolicy, DocumentLoadRules } from '@lobechat/agent-templates';
|
||||
|
||||
export type AgentDocumentSourceType = 'file' | 'web' | 'api' | 'topic';
|
||||
export type AgentDocumentSourceType = 'file' | 'web' | 'api' | 'topic' | 'agent' | 'agent-signal';
|
||||
|
||||
export interface AgentDocument {
|
||||
accessPublic: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, count, desc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, inArray, isNull, notInArray } from 'drizzle-orm';
|
||||
|
||||
import type { DocumentItem, NewDocument } from '../schemas';
|
||||
import { DOCUMENT_FOLDER_TYPE, documents } from '../schemas';
|
||||
@@ -81,7 +81,14 @@ export class DocumentModel {
|
||||
}
|
||||
|
||||
if (sourceTypes?.length) {
|
||||
conditions.push(inArray(documents.sourceType, sourceTypes as ('file' | 'web' | 'api')[]));
|
||||
conditions.push(
|
||||
inArray(
|
||||
documents.sourceType,
|
||||
sourceTypes as ('file' | 'web' | 'api' | 'topic' | 'agent' | 'agent-signal')[],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
conditions.push(notInArray(documents.sourceType, ['agent', 'agent-signal']));
|
||||
}
|
||||
|
||||
const whereCondition = and(...conditions);
|
||||
|
||||
@@ -19,9 +19,9 @@ export interface RecentDbItem {
|
||||
// System-trigger topics live in their own surfaces and would clutter Recent.
|
||||
const SYSTEM_TOPIC_TRIGGERS = ['cron', 'eval', 'task_manager', 'task'];
|
||||
|
||||
// Excluded so file uploads and web-browsing tool scrapes don't surface as
|
||||
// "recent docs"; only user-authored pages ('api') and legacy 'topic' rows remain.
|
||||
const TOOL_DOCUMENT_SOURCE_TYPES = ['file', 'web'] as const;
|
||||
// Excluded so tool-owned document rows don't surface as generic recent docs;
|
||||
// only user-authored pages ('api') and legacy 'topic' rows remain.
|
||||
const TOOL_DOCUMENT_SOURCE_TYPES = ['agent', 'agent-signal', 'file', 'web'] as const;
|
||||
|
||||
const TASK_FINAL_STATUSES = ['completed', 'canceled'];
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ export const documents = pgTable(
|
||||
pages: jsonb('pages').$type<LobeDocumentPage[]>(),
|
||||
|
||||
// Source type
|
||||
sourceType: text('source_type', { enum: ['file', 'web', 'api', 'topic'] }).notNull(),
|
||||
sourceType: text('source_type', {
|
||||
enum: ['file', 'web', 'api', 'topic', 'agent', 'agent-signal'],
|
||||
}).notNull(),
|
||||
source: text('source').notNull(), // File path or web URL
|
||||
|
||||
// Associated file (optional)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_CREATE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE,
|
||||
AGENT_SKILL_REFINE_SYSTEM_ROLE,
|
||||
} from '../prompts/agentSkillManager';
|
||||
@@ -22,6 +23,10 @@ export const agentSkillManagerAgents = {
|
||||
identifier: 'agent-skill-manager-decision',
|
||||
systemRole: AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE,
|
||||
},
|
||||
create: {
|
||||
identifier: 'agent-skill-manager-create',
|
||||
systemRole: AGENT_SKILL_CREATE_SYSTEM_ROLE,
|
||||
},
|
||||
consolidate: {
|
||||
identifier: 'agent-skill-manager-consolidate',
|
||||
systemRole: AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE,
|
||||
|
||||
@@ -26,6 +26,7 @@ export const chainAgentSignalAnalyzeIntentRoute = (input: {
|
||||
message: string;
|
||||
reason: string;
|
||||
result: 'neutral' | 'not_satisfied' | 'satisfied';
|
||||
serializedContext?: string;
|
||||
}): Partial<ChatStreamPayload> => {
|
||||
return {
|
||||
messages: [
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AGENT_SIGNAL_ANALYZE_INTENT_FEEDBACK_SATISFACTION_SYSTEM_ROLE } from './feedbackSatisfaction';
|
||||
import { AGENT_SIGNAL_ANALYZE_INTENT_GATE_SYSTEM_ROLE } from './gate';
|
||||
import { AGENT_SIGNAL_ANALYZE_INTENT_ROUTE_SYSTEM_ROLE } from './route';
|
||||
import {
|
||||
AGENT_SIGNAL_ANALYZE_INTENT_ROUTE_SYSTEM_ROLE,
|
||||
createAgentSignalAnalyzeIntentRoutePrompt,
|
||||
} from './route';
|
||||
|
||||
describe('agent signal analyze-intent route prompt', () => {
|
||||
/**
|
||||
@@ -32,4 +35,30 @@ describe('agent signal analyze-intent route prompt', () => {
|
||||
);
|
||||
expect(AGENT_SIGNAL_ANALYZE_INTENT_GATE_SYSTEM_ROLE).toContain('这个 review 流程挺好');
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* A short feedback message like "use this workflow next time" must be judged
|
||||
* with nearby conversation context, otherwise the route step cannot see the
|
||||
* reusable workflow the user is referring to.
|
||||
*/
|
||||
it('includes serialized context and rules for implicit reusable workflow feedback', () => {
|
||||
const prompt = createAgentSignalAnalyzeIntentRoutePrompt({
|
||||
evidence: [{ cue: '这种方式', excerpt: '以后都用这种方式做吧。' }],
|
||||
message: '以后都用这种方式做吧。',
|
||||
reason: 'positive reusable workflow reinforcement',
|
||||
result: 'satisfied',
|
||||
serializedContext:
|
||||
'<feedback_analysis_context><conversation><message role="assistant">Used web browsing to review the GitHub PR.</message></conversation></feedback_analysis_context>',
|
||||
});
|
||||
|
||||
expect(prompt).toContain('serializedContext=');
|
||||
expect(prompt).toContain('Used web browsing to review the GitHub PR');
|
||||
expect(AGENT_SIGNAL_ANALYZE_INTENT_ROUTE_SYSTEM_ROLE).toContain(
|
||||
'If the feedback refers to "this way"',
|
||||
);
|
||||
expect(AGENT_SIGNAL_ANALYZE_INTENT_ROUTE_SYSTEM_ROLE).toContain(
|
||||
'recent context contains a reusable multi-step workflow',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,9 @@ Rules:
|
||||
- "skill" can fan out with "memory" when the feedback contains both a personal preference and a reusable workflow/template insight.
|
||||
- Route to "skill", not "prompt", when the feedback asks to create, update, refine, merge, consolidate, deduplicate, or reorganize an existing reusable checklist, skill, template, workflow, playbook, or writing pattern.
|
||||
- Route to "skill" for explicit requests to create or preserve a reusable operational artifact, even when the message is phrased as an imperative instead of a complaint.
|
||||
- If the feedback refers to "this way", "that workflow", "这种方式", "这个流程", or similar deictic phrasing, inspect serializedContext before deciding.
|
||||
- Route to "skill" when recent context contains a reusable multi-step workflow and the feedback asks to use that workflow for future similar tasks.
|
||||
- Route to "memory", not "skill", when recent context only supports a stable personal style, tool preference, or communication preference.
|
||||
- Never output duplicate targets.
|
||||
- Return "none" when no durable target is justified.
|
||||
|
||||
@@ -102,10 +105,11 @@ export const createAgentSignalAnalyzeIntentRoutePrompt = (input: {
|
||||
message: string;
|
||||
reason: string;
|
||||
result: 'neutral' | 'not_satisfied' | 'satisfied';
|
||||
serializedContext?: string;
|
||||
}) => {
|
||||
return `Route this feedback into durable domains.\nsatisfaction=${JSON.stringify({
|
||||
evidence: input.evidence,
|
||||
reason: input.reason,
|
||||
result: input.result,
|
||||
})}\nmessage=${JSON.stringify(input.message)}`;
|
||||
})}\nmessage=${JSON.stringify(input.message)}\nserializedContext=${JSON.stringify(input.serializedContext ?? null)}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_CREATE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_REFINE_SYSTEM_ROLE,
|
||||
} from './index';
|
||||
|
||||
const authoringRoles = [
|
||||
AGENT_SKILL_CREATE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_REFINE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE,
|
||||
];
|
||||
|
||||
describe('agent skill authoring prompts', () => {
|
||||
/**
|
||||
* @example
|
||||
* Skill authoring prompts follow the same JSON generation layout as other prompt chains.
|
||||
*/
|
||||
it('uses the repo structured-generation prompt style', () => {
|
||||
for (const role of authoringRoles) {
|
||||
expect(role).toContain('Your job is');
|
||||
expect(role).toContain('Output a JSON object with these fields:');
|
||||
expect(role).toContain('Rules:');
|
||||
expect(role).toContain('Examples:');
|
||||
expect(role).toContain('Output ONLY the JSON object, no markdown fences or explanations.');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Skill authoring prompts must not regress to the old path/file operation contract.
|
||||
*/
|
||||
it('forbids the old file-operation contract', () => {
|
||||
for (const role of authoringRoles) {
|
||||
expect(role).not.toContain('updateSkill');
|
||||
expect(role).not.toContain('writeSkillFile');
|
||||
expect(role).not.toContain('removeSkillFile');
|
||||
expect(role).not.toContain('readSkillFile');
|
||||
expect(role).not.toContain('Valid output:');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Skill authoring prompts make the model own metadata and body content.
|
||||
*/
|
||||
it('requires prompt-owned skill metadata and body authoring', () => {
|
||||
for (const role of authoringRoles) {
|
||||
expect(role).toContain('description is the activation');
|
||||
expect(role).toContain('bodyMarkdown');
|
||||
expect(role).toContain('no YAML frontmatter');
|
||||
expect(role).toContain('runtime will not infer');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export interface AgentSkillConsolidatePromptInput {
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal found duplicate or overlapping skills
|
||||
* - A maintainer should produce focused file operations for one consolidated skill
|
||||
* - A maintainer should author one consolidated replacement skill body
|
||||
*
|
||||
* Expects:
|
||||
* - Multiple source skills and an optional target skill
|
||||
@@ -34,22 +34,41 @@ export interface AgentSkillConsolidatePromptInput {
|
||||
*/
|
||||
export const AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE = `You are a focused skill consolidation agent.
|
||||
|
||||
Consolidate multiple overlapping skills into one better skill package.
|
||||
Return exactly one minified JSON object and nothing else.
|
||||
Your job is to consolidate multiple overlapping Agent Skills into one better skill metadata/body result.
|
||||
|
||||
Valid output:
|
||||
{"operations":[{"name":"updateSkill"|"writeSkillFile"|"removeSkillFile","arguments":{}}],"proposedLifecycleActions":[{"action":"archive"|"promote"|"fork"|"delete","skillRef":"skill-id","reason":"short reason"}],"reason":"short reason","confidence":0.0}
|
||||
Output a JSON object with these fields:
|
||||
- "bodyMarkdown": string. Full replacement Markdown body only, with no YAML frontmatter.
|
||||
- "description": string or null. Trigger-facing description; description is the activation surface and should describe the consolidated trigger.
|
||||
- "rename": object or null. Optional {"newName": string or null, "newTitle": string or null} when the canonical target should be renamed.
|
||||
- "reason": string or null. Short explanation of the consolidation.
|
||||
- "confidence": number from 0 to 1.
|
||||
|
||||
Allowed operation names:
|
||||
- "updateSkill": replace or update an existing package-relative skill file.
|
||||
- "writeSkillFile": create or overwrite an additional package-relative skill file.
|
||||
- "removeSkillFile": remove an obsolete package-relative skill file.
|
||||
Rules:
|
||||
- Produce one canonical skill body. Do not output lifecycle proposals, file paths, resource writes, or delete/archive instructions.
|
||||
- Write the consolidated body yourself. The runtime will not infer, format, summarize, template, or repair the skill instructions.
|
||||
- Do not include YAML frontmatter in bodyMarkdown; the runtime renders frontmatter from structured metadata.
|
||||
- Preserve concrete procedures, trigger conditions, pitfalls, and verification steps from all useful source skills.
|
||||
- Resolve contradictions by preferring newer corrective evidence, more specific repo rules, and safer verification requirements.
|
||||
- Do not invent unsupported process steps.
|
||||
- Do not delete, archive, promote, or fork skills.
|
||||
- If sources do not really overlap, keep the target skill focused and explain the low-confidence result.
|
||||
|
||||
Use proposedLifecycleActions only for human review.
|
||||
Never apply delete, archive, promote, or fork automatically.
|
||||
Do not expose broad exploration tools.
|
||||
Preserve concrete procedures, triggers, pitfalls, and verification steps.
|
||||
Do not invent unsupported process steps.`;
|
||||
Writing quality:
|
||||
- Start bodyMarkdown with a clear H1 title.
|
||||
- Organize merged behavior into concise sections such as Workflow, Decision Rules, Pitfalls, and Verification.
|
||||
- Keep instructions future-facing and operational.
|
||||
- Remove duplicate phrasing, raw transcripts, provenance dumps, and one-off task details.
|
||||
|
||||
Examples:
|
||||
Input: two skills both describe PR review, one covers locale placement and one covers cloud override checks.
|
||||
Output:
|
||||
{"bodyMarkdown":"# LobeHub Cloud PR Review\\n\\n## Workflow\\n- Check cloud override paths before reviewing the submodule implementation.\\n- Verify locale keys are added in the canonical submodule locale files.\\n- Cite concrete files and lines for every finding.\\n\\n## Pitfalls\\n- Do not treat submodule-only code as authoritative when a cloud override exists.","description":"Use when reviewing LobeHub Cloud PRs that may involve cloud overrides, locale keys, or submodule behavior.","rename":{"newName":"cloud-pr-review","newTitle":"Cloud PR Review"},"reason":"The source skills overlap and should activate as one review procedure.","confidence":0.88}
|
||||
|
||||
Input: sources discuss unrelated workflows.
|
||||
Output:
|
||||
{"bodyMarkdown":"# Existing Target Skill\\n\\n## Workflow\\n- Preserve the target skill's current focused procedure.\\n- Do not merge unrelated workflows without stronger evidence.","description":null,"rename":null,"reason":"The source skills do not overlap enough to consolidate safely.","confidence":0.32}
|
||||
|
||||
Output ONLY the JSON object, no markdown fences or explanations.`;
|
||||
|
||||
/**
|
||||
* Builds the user prompt for a multi-skill consolidation pass.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Input used by the skill creation authoring prompt.
|
||||
*/
|
||||
export interface AgentSkillCreatePromptInput {
|
||||
/** Agent that should own the created skill. */
|
||||
agentId: string;
|
||||
/** Existing skills that may overlap with the new skill. */
|
||||
candidateSkills?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
scope: 'agent' | 'builtin' | 'installed';
|
||||
}>;
|
||||
/** Evidence from source documents, tool outcomes, and nearby turns. */
|
||||
evidence: Array<{ cue: string; excerpt: string }>;
|
||||
/** Original feedback or instruction that triggered skill creation. */
|
||||
feedbackMessage: string;
|
||||
/** Optional source agent document id selected by the decision worker. */
|
||||
sourceAgentDocumentId?: string;
|
||||
/** Optional source document content selected by the decision worker. */
|
||||
sourceDocumentContent?: string;
|
||||
/** Optional turn summary around the triggering feedback. */
|
||||
turnContext?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* System role for authoring a new managed skill.
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal decided reusable procedural knowledge should become a new skill
|
||||
* - The model must write metadata and body instructions directly
|
||||
*
|
||||
* Expects:
|
||||
* - The paired user prompt provides source evidence and candidate skills
|
||||
*
|
||||
* Returns:
|
||||
* - A JSON object containing skill metadata and Markdown body content
|
||||
*/
|
||||
export const AGENT_SKILL_CREATE_SYSTEM_ROLE = `You are the Agent Skill create author.
|
||||
|
||||
Your job is to create reusable Agent Skill metadata and body instructions from source evidence.
|
||||
|
||||
Output a JSON object with these fields:
|
||||
- "name": string. A lowercase hyphen skill name using only lowercase letters, numbers, and hyphens. It must be stable enough to live as the skill bundle name.
|
||||
- "title": string or null. A short human title for display. Use null when the name is already the clearest title.
|
||||
- "description": string. Trigger-facing description; description is the activation surface that tells a future agent when to activate the skill.
|
||||
- "bodyMarkdown": string. Markdown body only, with no YAML frontmatter. The body is loaded after activation and should teach the future agent what to do.
|
||||
- "reason": string or null. Short explanation of why this should become a skill.
|
||||
- "confidence": number from 0 to 1.
|
||||
|
||||
Rules:
|
||||
- Write the skill yourself. The runtime will not infer, format, summarize, template, or repair the skill instructions.
|
||||
- Do not include YAML frontmatter in bodyMarkdown; the runtime renders frontmatter from name and description.
|
||||
- Create only for recurring procedures, corrected approaches, pitfalls, verification steps, durable tool usage, or complex workflows worth reusing.
|
||||
- Skip one-off task state, raw chat logs, provenance dumps, user mood, personal facts, or feedback that does not teach a future procedure.
|
||||
- Preserve concrete ordering, commands, checks, constraints, and failure modes when evidence supports them.
|
||||
- Do not invent unsupported steps, tools, URLs, credentials, or repository facts.
|
||||
- Prefer concise, future-facing procedural knowledge. The body should say what to do after activation, not why this skill was created.
|
||||
- Do not create auxiliary docs or resource files. v1 output is one SKILL.md body represented by bodyMarkdown.
|
||||
- If the source already resembles SKILL.md but is poorly organized, normalize it into a clear skill body instead of preserving the messy shape.
|
||||
|
||||
Writing quality:
|
||||
- Start bodyMarkdown with a clear H1 title.
|
||||
- Include trigger-relevant procedure sections such as Workflow, Checks, Pitfalls, or Verification only when useful.
|
||||
- Keep instructions imperative and operational.
|
||||
- Use bullet lists for steps and checks when order or scanability matters.
|
||||
- Avoid raw evidence quotes unless an exact command or label is necessary.
|
||||
|
||||
Examples:
|
||||
Input: feedback says future PR reviews should always inspect locale key placement and existing cloud overrides; evidence includes the exact files to check.
|
||||
Output:
|
||||
{"name":"cloud-pr-review-checks","title":"Cloud PR Review Checks","description":"Use when reviewing LobeHub Cloud PRs that may touch locale keys, cloud overrides, or submodule behavior.","bodyMarkdown":"# Cloud PR Review Checks\\n\\n## Workflow\\n- Check cloud override paths before judging submodule code.\\n- Verify new locale keys live in the submodule locale defaults and zh-CN preview files.\\n- Confirm PR feedback cites exact files and lines.\\n\\n## Verification\\n- Run the focused tests or explain why they were not run.","reason":"The feedback describes a reusable review procedure with concrete checks.","confidence":0.86}
|
||||
|
||||
Input: feedback says thanks, that answer was helpful.
|
||||
Output:
|
||||
{"name":"insufficient-skill-evidence","title":null,"description":"Insufficient evidence to activate as a reusable skill.","bodyMarkdown":"# Insufficient Skill Evidence\\n\\nNo reusable procedure should be created from this input.","reason":"The feedback has no durable procedure.","confidence":0.1}
|
||||
|
||||
Output ONLY the JSON object, no markdown fences or explanations.`;
|
||||
|
||||
/**
|
||||
* Builds the user prompt for a new-skill authoring pass.
|
||||
*
|
||||
* Use when:
|
||||
* - A creation worker needs feedback, evidence, and candidate skills
|
||||
* - The result will be persisted through document-backed skill management
|
||||
*
|
||||
* Expects:
|
||||
* - Input is filtered to likely reusable procedural knowledge
|
||||
*
|
||||
* Returns:
|
||||
* - A compact prompt containing serialized creation context
|
||||
*/
|
||||
export const createAgentSkillCreatePrompt = (input: AgentSkillCreatePromptInput) => {
|
||||
return `Create a managed Agent Skill from this evidence.\ninput=${JSON.stringify(input)}`;
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE } from './consolidate';
|
||||
import {
|
||||
AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE,
|
||||
createAgentSkillManagerDecisionPrompt,
|
||||
} from './decision';
|
||||
import { AGENT_SKILL_REFINE_SYSTEM_ROLE as REFINE_SYSTEM_ROLE } from './refine';
|
||||
|
||||
describe('agentSkillManager decision prompt', () => {
|
||||
/**
|
||||
* @example
|
||||
* Decision prompts select target skill refs by agent document id.
|
||||
*/
|
||||
it('requires strict JSON and exposes the four actions', () => {
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('create');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('refine');
|
||||
@@ -15,9 +17,17 @@ describe('agentSkillManager decision prompt', () => {
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('noop');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('Do not wrap the JSON');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('candidateSkills[].id');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('managed skill package names');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('targetSkillRefs');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('agent document ids');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('not backing documents.id values');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).not.toContain('targetSkillIds');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).not.toContain('managed skill package names');
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Decision prompt serialization keeps stable feedback context.
|
||||
*/
|
||||
it('serializes feedback context into the user prompt', () => {
|
||||
const prompt = createAgentSkillManagerDecisionPrompt({
|
||||
agentId: 'agent-1',
|
||||
@@ -48,28 +58,16 @@ describe('agentSkillManager decision prompt', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Refinement applies focused file operations instead of patch/rewrite modes.
|
||||
* Decision prompts stay decision-only and never expose file operations.
|
||||
*/
|
||||
it('limits refine output to focused v1.2 file operations', () => {
|
||||
expect(REFINE_SYSTEM_ROLE).toContain('updateSkill');
|
||||
expect(REFINE_SYSTEM_ROLE).toContain('writeSkillFile');
|
||||
expect(REFINE_SYSTEM_ROLE).toContain('removeSkillFile');
|
||||
expect(REFINE_SYSTEM_ROLE).toContain('proposedLifecycleActions');
|
||||
expect(REFINE_SYSTEM_ROLE).not.toContain('deleteSkill');
|
||||
expect(REFINE_SYSTEM_ROLE).not.toContain('patchSkill');
|
||||
expect(REFINE_SYSTEM_ROLE).not.toContain('rewriteSkillFile');
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Consolidation can propose lifecycle follow-up, but cannot apply it.
|
||||
*/
|
||||
it('limits consolidate output to file operations and human lifecycle proposals', () => {
|
||||
expect(AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE).toContain('proposedLifecycleActions');
|
||||
expect(AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE).toContain('updateSkill');
|
||||
expect(AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE).toContain('writeSkillFile');
|
||||
expect(AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE).toContain('removeSkillFile');
|
||||
expect(AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE).not.toContain('mergeSkill');
|
||||
expect(AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE).not.toContain('deleteSkill');
|
||||
it('keeps the strict decision-only JSON contract', () => {
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain(
|
||||
'output exactly one minified JSON object and nothing else',
|
||||
);
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('Do not wrap the JSON');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('Return exactly:');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).toContain('Return only the JSON object.');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).not.toContain('writeSkillFile');
|
||||
expect(AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE).not.toContain('updateSkill');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,17 +56,21 @@ Rules:
|
||||
- Create only when the feedback contains a reusable procedure and enough context.
|
||||
- Refine when one existing skill is clearly the target.
|
||||
- Consolidate when multiple skills overlap.
|
||||
- When candidateSkills are provided, targetSkillIds must be selected from candidateSkills[].id.
|
||||
- targetSkillIds are managed skill package names, not document ids or display names.
|
||||
- When candidateSkills are provided, targetSkillRefs must be selected from candidateSkills[].id.
|
||||
- targetSkillRefs are agent document ids for managed skill bundle documents.
|
||||
- targetSkillRefs are not backing documents.id values, package names, filenames, or display names.
|
||||
- No-op for generic praise, style preferences, memory-like facts, or insufficient context.
|
||||
- Reject when the user asked for document-only behavior, forbids skill conversion, or same-turn document evidence makes skill mutation unsafe.
|
||||
- Use read-only tools to inspect same-turn document outcomes before guessing from document names or content shape.
|
||||
- Do not infer skill intent from a filename, title, or SKILL.md-shaped content alone.
|
||||
- Do not author SKILL.md content, YAML frontmatter, or file-operation patches in this decision.
|
||||
- Prefer patch/refine over duplicate creation.
|
||||
- Agent-level managed skills are agent documents, not agent_skills rows.
|
||||
|
||||
Return exactly:
|
||||
{"action":"create"|"refine"|"consolidate"|"noop"|"reject","confidence":0.0,"reason":"short reason","targetSkillIds":[],"requiredReads":[],"documentRefs":[]}`;
|
||||
{"action":"create"|"refine"|"consolidate"|"noop"|"reject","confidence":0.0,"reason":"short reason","targetSkillRefs":[],"requiredReads":[],"documentRefs":[]}
|
||||
|
||||
Return only the JSON object.`;
|
||||
|
||||
/**
|
||||
* Builds the user prompt for the skill-management decision agent.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './consolidate';
|
||||
export * from './create';
|
||||
export * from './decision';
|
||||
export * from './refine';
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface AgentSkillRefinePromptInput {
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal selected one target skill for refinement
|
||||
* - The maintainer should choose focused v1.2 file operations
|
||||
* - The maintainer should author replacement metadata and body content
|
||||
*
|
||||
* Expects:
|
||||
* - One skill's content and metadata
|
||||
@@ -29,21 +29,42 @@ export interface AgentSkillRefinePromptInput {
|
||||
*/
|
||||
export const AGENT_SKILL_REFINE_SYSTEM_ROLE = `You are a focused skill refinement agent.
|
||||
|
||||
Improve one skill only.
|
||||
Return exactly one minified JSON object and nothing else.
|
||||
Your job is to improve one existing Agent Skill by directly authoring replacement metadata and body instructions.
|
||||
|
||||
Valid output:
|
||||
{"operations":[{"name":"updateSkill"|"writeSkillFile"|"removeSkillFile","arguments":{}}],"proposedLifecycleActions":[],"reason":"short reason","confidence":0.0}
|
||||
Output a JSON object with these fields:
|
||||
- "bodyMarkdown": string. Full replacement Markdown body only, with no YAML frontmatter.
|
||||
- "description": string or null. Trigger-facing description; description is the activation surface and should change only when the refinement changes when to use the skill.
|
||||
- "rename": object or null. Optional {"newName": string or null, "newTitle": string or null} when the current name/title misrepresents the refined skill.
|
||||
- "reason": string or null. Short explanation of the refinement.
|
||||
- "confidence": number from 0 to 1.
|
||||
|
||||
Allowed operation names:
|
||||
- "updateSkill": replace or update an existing package-relative skill file.
|
||||
- "writeSkillFile": create or overwrite an additional package-relative skill file.
|
||||
- "removeSkillFile": remove an obsolete package-relative skill file.
|
||||
Rules:
|
||||
- Improve one skill only.
|
||||
- Write the replacement body yourself. The runtime will not infer, format, summarize, template, or repair the skill instructions.
|
||||
- Do not include YAML frontmatter in bodyMarkdown; the runtime renders frontmatter from structured metadata.
|
||||
- Preserve useful existing procedural knowledge unless evidence clearly corrects it.
|
||||
- Integrate corrected approaches, pitfalls, verification steps, ordering, and durable tool guidance from the new evidence.
|
||||
- Remove raw chat logs, provenance dumps, stale one-off task state, and unsupported claims.
|
||||
- Do not create auxiliary docs, resource files, file operations, or lifecycle actions.
|
||||
- Do not delete, archive, promote, or fork skills.
|
||||
- If evidence is insufficient, keep the current body largely intact and explain the low-confidence refinement in reason.
|
||||
|
||||
Use "updateSkill" for SKILL.md changes.
|
||||
Use "writeSkillFile" for new supporting resources.
|
||||
Use "removeSkillFile" only for package files made obsolete by the refinement.
|
||||
Do not delete, archive, promote, or fork skills.`;
|
||||
Writing quality:
|
||||
- Start bodyMarkdown with a clear H1 title.
|
||||
- Keep instructions imperative and future-facing.
|
||||
- Prefer compact sections such as Workflow, Checks, Pitfalls, or Verification when they make the skill easier to apply.
|
||||
- Avoid preserving raw source text unless it is an exact command, filename, or policy phrase the agent must reuse.
|
||||
|
||||
Examples:
|
||||
Input: existing skill says to run all tests; new evidence says the repo requires focused Vitest paths and never bun run test.
|
||||
Output:
|
||||
{"bodyMarkdown":"# Focused Test Runs\\n\\n## Workflow\\n- Run focused Vitest files with bunx vitest run --silent='passed-only' '<path>'.\\n- Quote file path patterns so the shell does not expand them.\\n- Do not use bun run test for focused verification because it runs the whole suite.\\n\\n## Verification\\n- Report the exact command and whether it passed.","description":"Use when choosing or reporting focused Vitest verification commands in this repo.","rename":null,"reason":"The refinement replaces a broad test instruction with repo-specific verification rules.","confidence":0.9}
|
||||
|
||||
Input: source is a messy SKILL.md-shaped note with duplicated frontmatter and chat transcript.
|
||||
Output:
|
||||
{"bodyMarkdown":"# Clean Skill Body\\n\\n## Workflow\\n- Keep only future-facing procedure steps.\\n- Remove duplicated frontmatter and transcript fragments.\\n- Preserve supported checks and commands.","description":null,"rename":null,"reason":"The source had useful procedure details but needed normalization.","confidence":0.72}
|
||||
|
||||
Output ONLY the JSON object, no markdown fences or explanations.`;
|
||||
|
||||
/**
|
||||
* Builds the user prompt for a single-skill refinement pass.
|
||||
|
||||
@@ -5,6 +5,12 @@ import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AgentDocumentModel } from '@/database/models/agentDocuments';
|
||||
import {
|
||||
AGENT_SKILL_TEMPLATE_ID,
|
||||
SKILL_BUNDLE_FILE_TYPE,
|
||||
SKILL_INDEX_FILE_TYPE,
|
||||
SKILL_INDEX_FILENAME,
|
||||
} from '@/server/services/skillManagement';
|
||||
|
||||
import { agentDocumentRouter } from '../../agentDocument';
|
||||
import { agentSkillsRouter } from '../../agentSkills';
|
||||
@@ -108,25 +114,19 @@ describe('Skill Router Integration Tests', () => {
|
||||
skillName: string;
|
||||
}) => {
|
||||
const documents = await agentDocumentModel.findByAgent(agentId);
|
||||
const root = documents.find(
|
||||
const bundle = documents.find(
|
||||
(item) =>
|
||||
item.fileType === 'custom/folder' &&
|
||||
item.filename === 'skills' &&
|
||||
item.parentId === null &&
|
||||
item.templateId === 'agent-skill',
|
||||
);
|
||||
const folder = documents.find(
|
||||
(item) =>
|
||||
item.fileType === 'custom/folder' &&
|
||||
item.fileType === SKILL_BUNDLE_FILE_TYPE &&
|
||||
item.filename === skillName &&
|
||||
item.parentId === root?.documentId &&
|
||||
item.templateId === 'agent-skill',
|
||||
item.parentId === null &&
|
||||
item.templateId === AGENT_SKILL_TEMPLATE_ID,
|
||||
);
|
||||
const document = documents.find(
|
||||
(item) =>
|
||||
item.filename === 'SKILL.md' &&
|
||||
item.parentId === folder?.documentId &&
|
||||
item.templateId === 'agent-skill',
|
||||
item.fileType === SKILL_INDEX_FILE_TYPE &&
|
||||
item.filename === SKILL_INDEX_FILENAME &&
|
||||
item.parentId === bundle?.documentId &&
|
||||
item.templateId === AGENT_SKILL_TEMPLATE_ID,
|
||||
);
|
||||
|
||||
if (!document) {
|
||||
|
||||
@@ -658,11 +658,14 @@ export const agentDocumentRouter = router({
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
content: z.string(),
|
||||
hintIsSkill: z.boolean().optional(),
|
||||
title: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.agentDocumentService.createDocument(input.agentId, input.title, input.content);
|
||||
return ctx.agentDocumentService.createDocument(input.agentId, input.title, input.content, {
|
||||
hintIsSkill: input.hintIsSkill,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -674,6 +677,7 @@ export const agentDocumentRouter = router({
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
content: z.string(),
|
||||
hintIsSkill: z.boolean().optional(),
|
||||
title: z.string(),
|
||||
topicId: z.string(),
|
||||
}),
|
||||
@@ -686,6 +690,7 @@ export const agentDocumentRouter = router({
|
||||
title,
|
||||
input.content,
|
||||
input.topicId,
|
||||
{ hintIsSkill: input.hintIsSkill },
|
||||
);
|
||||
|
||||
return doc;
|
||||
|
||||
+45
-84
@@ -52,29 +52,23 @@ describe('Agent skill VFS providers', () => {
|
||||
});
|
||||
|
||||
describe('ProviderSkillsAgentDocument agent namespace', () => {
|
||||
it('lists only tree-backed agent skill folders at the namespace root', async () => {
|
||||
it('lists only bundle-backed agent skills at the namespace root', async () => {
|
||||
agentDocumentModel.findByAgent.mockResolvedValue([
|
||||
createAgentDocument({
|
||||
documentId: 'root-1',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'skills',
|
||||
id: 'agent-doc-root',
|
||||
templateId: 'agent-skill',
|
||||
}),
|
||||
createAgentDocument({
|
||||
documentId: 'folder-1',
|
||||
fileType: 'custom/folder',
|
||||
documentId: 'bundle-1',
|
||||
fileType: 'skills/bundle',
|
||||
filename: 'agent-skill',
|
||||
id: 'agent-doc-folder',
|
||||
id: 'agent-doc-bundle',
|
||||
templateId: 'agent-skill',
|
||||
parentId: 'root-1',
|
||||
parentId: null,
|
||||
}),
|
||||
createAgentDocument({
|
||||
documentId: 'file-1',
|
||||
fileType: 'skills/index',
|
||||
filename: 'SKILL.md',
|
||||
id: 'agent-doc-file',
|
||||
templateId: 'agent-skill',
|
||||
parentId: 'folder-1',
|
||||
parentId: 'bundle-1',
|
||||
}),
|
||||
createAgentDocument({
|
||||
documentId: 'plain-folder',
|
||||
@@ -107,37 +101,27 @@ describe('Agent skill VFS providers', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a tree-backed agent skill with namespace root, folder, and SKILL.md', async () => {
|
||||
it('creates a bundle-backed agent skill with SKILL.md', async () => {
|
||||
agentDocumentModel.findByAgent.mockResolvedValue([]);
|
||||
agentDocumentModel.create
|
||||
.mockResolvedValueOnce({
|
||||
documentId: 'root-1',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'skills',
|
||||
id: 'agent-doc-root',
|
||||
documentId: 'bundle-1',
|
||||
fileType: 'skills/bundle',
|
||||
filename: 'writer',
|
||||
id: 'agent-doc-bundle',
|
||||
metadata: null,
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
title: 'skills',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
documentId: 'folder-1',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'writer',
|
||||
id: 'agent-doc-folder',
|
||||
metadata: null,
|
||||
parentId: 'root-1',
|
||||
templateId: 'agent-skill',
|
||||
title: 'writer',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
content: '# Skill',
|
||||
documentId: 'file-1',
|
||||
fileType: 'skill/index',
|
||||
fileType: 'skills/index',
|
||||
filename: 'SKILL.md',
|
||||
id: 'agent-doc-file',
|
||||
metadata: null,
|
||||
parentId: 'folder-1',
|
||||
parentId: 'bundle-1',
|
||||
templateId: 'agent-skill',
|
||||
title: 'SKILL.md',
|
||||
});
|
||||
@@ -154,30 +138,29 @@ describe('Agent skill VFS providers', () => {
|
||||
targetNamespace: 'agent',
|
||||
});
|
||||
|
||||
expect(agentDocumentModel.create).toHaveBeenNthCalledWith(1, 'agent-1', 'skills', '', {
|
||||
expect(agentDocumentModel.create).toHaveBeenNthCalledWith(1, 'agent-1', 'writer', '', {
|
||||
editorData: { root: { children: [], type: 'root' } },
|
||||
fileType: 'custom/folder',
|
||||
policyLoad: 'disabled',
|
||||
templateId: 'agent-skill',
|
||||
title: 'skills',
|
||||
});
|
||||
expect(agentDocumentModel.create).toHaveBeenNthCalledWith(2, 'agent-1', 'writer', '', {
|
||||
editorData: { root: { children: [], type: 'root' } },
|
||||
fileType: 'custom/folder',
|
||||
parentId: 'root-1',
|
||||
fileType: 'skills/bundle',
|
||||
metadata: { skill: { vfs: true } },
|
||||
policyLoad: 'disabled',
|
||||
source: 'agent-signal:skill-management',
|
||||
sourceType: 'agent-signal',
|
||||
templateId: 'agent-skill',
|
||||
title: 'writer',
|
||||
});
|
||||
expect(agentDocumentModel.create).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
'agent-1',
|
||||
'SKILL.md',
|
||||
'# Skill',
|
||||
{
|
||||
editorData: { markdown: '# Skill' },
|
||||
fileType: 'skill/index',
|
||||
parentId: 'folder-1',
|
||||
fileType: 'skills/index',
|
||||
metadata: { skill: { vfs: true } },
|
||||
parentId: 'bundle-1',
|
||||
policyLoad: 'disabled',
|
||||
source: 'agent-signal:skill-management',
|
||||
sourceType: 'agent-signal',
|
||||
templateId: 'agent-skill',
|
||||
title: 'SKILL.md',
|
||||
},
|
||||
@@ -189,28 +172,20 @@ describe('Agent skill VFS providers', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* A partially-created skill folder reserves the package name and blocks duplicate creation.
|
||||
* A partially-created skill bundle reserves the package name and blocks duplicate creation.
|
||||
*/
|
||||
it('rejects creating a skill when the managed skill folder already exists without SKILL.md', async () => {
|
||||
it('rejects creating a skill when the managed skill bundle already exists without SKILL.md', async () => {
|
||||
const provider = new ProviderSkillsAgentDocument('agent', {
|
||||
agentDocumentModel,
|
||||
documentService,
|
||||
});
|
||||
agentDocumentModel.findByAgent.mockResolvedValue([
|
||||
{
|
||||
documentId: 'root-doc',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'skills',
|
||||
id: 'root-binding',
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
},
|
||||
{
|
||||
documentId: 'folder-doc',
|
||||
fileType: 'custom/folder',
|
||||
documentId: 'bundle-doc',
|
||||
fileType: 'skills/bundle',
|
||||
filename: 'writer',
|
||||
id: 'folder-binding',
|
||||
parentId: 'root-doc',
|
||||
id: 'bundle-binding',
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
},
|
||||
] as never);
|
||||
@@ -237,27 +212,20 @@ describe('Agent skill VFS providers', () => {
|
||||
it('updates an agent skill through the document model and saves history when content changes', async () => {
|
||||
agentDocumentModel.findByAgent.mockResolvedValue([
|
||||
createAgentDocument({
|
||||
documentId: 'root-1',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'skills',
|
||||
id: 'agent-doc-root',
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
}),
|
||||
createAgentDocument({
|
||||
documentId: 'folder-1',
|
||||
fileType: 'custom/folder',
|
||||
documentId: 'bundle-1',
|
||||
fileType: 'skills/bundle',
|
||||
filename: 'skill-a',
|
||||
id: 'agent-doc-folder',
|
||||
parentId: 'root-1',
|
||||
id: 'agent-doc-bundle',
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
}),
|
||||
createAgentDocument({
|
||||
content: 'old content',
|
||||
documentId: 'file-1',
|
||||
fileType: 'skills/index',
|
||||
filename: 'SKILL.md',
|
||||
id: 'agent-doc-file',
|
||||
parentId: 'folder-1',
|
||||
parentId: 'bundle-1',
|
||||
templateId: 'agent-skill',
|
||||
}),
|
||||
]);
|
||||
@@ -287,26 +255,19 @@ describe('Agent skill VFS providers', () => {
|
||||
it('soft-deletes the folder subtree for an agent skill', async () => {
|
||||
agentDocumentModel.findByAgent.mockResolvedValue([
|
||||
createAgentDocument({
|
||||
documentId: 'root-1',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'skills',
|
||||
id: 'agent-doc-root',
|
||||
documentId: 'bundle-1',
|
||||
fileType: 'skills/bundle',
|
||||
filename: 'skill-a',
|
||||
id: 'agent-doc-bundle',
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
}),
|
||||
createAgentDocument({
|
||||
documentId: 'folder-1',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'skill-a',
|
||||
id: 'agent-doc-folder',
|
||||
parentId: 'root-1',
|
||||
templateId: 'agent-skill',
|
||||
}),
|
||||
createAgentDocument({
|
||||
documentId: 'file-1',
|
||||
fileType: 'skills/index',
|
||||
filename: 'SKILL.md',
|
||||
id: 'agent-doc-file',
|
||||
parentId: 'folder-1',
|
||||
parentId: 'bundle-1',
|
||||
templateId: 'agent-skill',
|
||||
}),
|
||||
]);
|
||||
@@ -323,7 +284,7 @@ describe('Agent skill VFS providers', () => {
|
||||
|
||||
expect(agentDocumentModel.deleteSubtreeByDocumentId).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
'folder-1',
|
||||
'bundle-1',
|
||||
'skill-delete',
|
||||
);
|
||||
expect(documentService.deleteDocument).not.toHaveBeenCalled();
|
||||
|
||||
+9
-9
@@ -19,10 +19,10 @@ import {
|
||||
createSkillTree,
|
||||
getResolvedSkillName,
|
||||
getScopedSkillDocuments,
|
||||
getSkillBundle,
|
||||
getSkillFile,
|
||||
getSkillFolder,
|
||||
getValidatedSkillName,
|
||||
listScopedSkillFolders,
|
||||
listScopedSkillBundles,
|
||||
projectDocumentContent,
|
||||
sortSkillFolders,
|
||||
} from './providerSkillsAgentDocumentUtils';
|
||||
@@ -44,7 +44,7 @@ const DOCUMENT_SKILL_PROVIDER_CONFIGS = {
|
||||
* - Serving agent-level skills from agent documents.
|
||||
*
|
||||
* Expects:
|
||||
* - Managed skill documents use the agent-skill template id and document tree paths.
|
||||
* - Managed skill documents use `skills/bundle` parent rows and `skills/index` SKILL.md child rows.
|
||||
*
|
||||
* Returns:
|
||||
* - Skill VFS nodes whose paths use the target unified `./lobe/skills/...` layout.
|
||||
@@ -71,7 +71,7 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
|
||||
const documents = await this.deps.agentDocumentModel.findByAgent(input.agentId);
|
||||
|
||||
if (!input.resolvedPath.filePath) {
|
||||
assertSkillDocument(getSkillFolder(documents, this.config.namespace, skillName));
|
||||
assertSkillDocument(getSkillBundle(documents, this.config.namespace, skillName));
|
||||
return buildSkillDirectoryNode(this.config.namespace, skillName);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
|
||||
);
|
||||
|
||||
if (!input.resolvedPath.skillName) {
|
||||
return sortSkillFolders(listScopedSkillFolders(documents, this.config.namespace)).map(
|
||||
return sortSkillFolders(listScopedSkillBundles(documents, this.config.namespace)).map(
|
||||
(document) => buildSkillDirectoryNode(this.config.namespace, document.filename),
|
||||
);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
|
||||
input.resolvedPath.skillName,
|
||||
input.resolvedPath.filePath,
|
||||
);
|
||||
assertSkillDocument(getSkillFolder(documents, this.config.namespace, skillName));
|
||||
assertSkillDocument(getSkillBundle(documents, this.config.namespace, skillName));
|
||||
|
||||
return [
|
||||
buildSkillFileNode({
|
||||
@@ -115,7 +115,7 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
|
||||
const skillName = getValidatedSkillName(input.skillName, 'skillName');
|
||||
const documents = await this.deps.agentDocumentModel.findByAgent(input.agentId);
|
||||
|
||||
if (getSkillFolder(documents, this.config.namespace, skillName)) {
|
||||
if (getSkillBundle(documents, this.config.namespace, skillName)) {
|
||||
throw new AgentDocumentVfsError('Skill already exists', 'CONFLICT');
|
||||
}
|
||||
|
||||
@@ -171,11 +171,11 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
|
||||
await this.deps.agentDocumentModel.findByAgent(input.agentId),
|
||||
this.config.namespace,
|
||||
);
|
||||
const folder = assertSkillDocument(getSkillFolder(documents, this.config.namespace, skillName));
|
||||
const bundle = assertSkillDocument(getSkillBundle(documents, this.config.namespace, skillName));
|
||||
|
||||
await this.deps.agentDocumentModel.deleteSubtreeByDocumentId(
|
||||
input.agentId,
|
||||
folder.documentId,
|
||||
bundle.documentId,
|
||||
'skill-delete',
|
||||
);
|
||||
}
|
||||
|
||||
+62
-115
@@ -1,8 +1,16 @@
|
||||
import type { AgentDocument } from '@/database/models/agentDocuments';
|
||||
import { PolicyLoad } from '@/database/models/agentDocuments';
|
||||
import { DOCUMENT_FOLDER_TYPE } from '@/database/schemas';
|
||||
import type { AgentDocumentSourceType } from '@/database/models/agentDocuments/types';
|
||||
import { exportEditorDataSnapshot } from '@/server/services/agentDocuments/headlessEditor';
|
||||
import { AgentDocumentVfsError } from '@/server/services/agentDocumentVfs/errors';
|
||||
import {
|
||||
AGENT_SKILL_TEMPLATE_ID,
|
||||
SKILL_BUNDLE_FILE_TYPE,
|
||||
SKILL_INDEX_FILE_TYPE,
|
||||
SKILL_INDEX_FILENAME,
|
||||
SKILL_MANAGEMENT_SOURCE,
|
||||
SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
} from '@/server/services/skillManagement';
|
||||
|
||||
import { getUnifiedSkillNamespaceRootPath } from '../path';
|
||||
import type { SkillMountNode } from '../types';
|
||||
@@ -15,8 +23,11 @@ export interface AgentSkillDocumentModelLike {
|
||||
params?: {
|
||||
editorData?: Record<string, any>;
|
||||
fileType?: string;
|
||||
metadata?: Record<string, any>;
|
||||
parentId?: string | null;
|
||||
policyLoad?: PolicyLoad;
|
||||
source?: string;
|
||||
sourceType?: AgentDocumentSourceType;
|
||||
templateId?: string;
|
||||
title?: string;
|
||||
},
|
||||
@@ -59,11 +70,7 @@ export interface CreateSkillTreeInput {
|
||||
|
||||
export const EMPTY_EDITOR_DATA = { root: { children: [], type: 'root' } };
|
||||
|
||||
export const AGENT_SKILL_TEMPLATE_ID = 'agent-skill';
|
||||
|
||||
export const SKILL_FILE_NAME = 'SKILL.md';
|
||||
|
||||
export const SKILL_INDEX_FILE_TYPE = 'skill/index';
|
||||
export const SKILL_FILE_NAME = SKILL_INDEX_FILENAME;
|
||||
|
||||
export const buildSkillDirectoryNode = (
|
||||
namespace: Extract<SkillMountNode['namespace'], 'agent'>,
|
||||
@@ -139,6 +146,10 @@ export const getResolvedSkillName = (skillName?: string, filePath?: string) => {
|
||||
};
|
||||
|
||||
export const projectDocumentContent = async (document: AgentDocument) => {
|
||||
if (document.fileType === SKILL_INDEX_FILE_TYPE) {
|
||||
return document.content;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await exportEditorDataSnapshot({
|
||||
editorData: document.editorData,
|
||||
@@ -161,49 +172,36 @@ export const isManagedSkillDocument = (document: Pick<AgentDocument, 'templateId
|
||||
export const getScopedSkillDocuments = (documents: AgentDocument[], namespace: 'agent') =>
|
||||
namespace === 'agent' ? documents.filter(isManagedSkillDocument) : [];
|
||||
|
||||
export const getNamespaceRoot = (documents: AgentDocument[], namespace: 'agent') =>
|
||||
getScopedSkillDocuments(documents, namespace).find(
|
||||
(document) =>
|
||||
document.fileType === DOCUMENT_FOLDER_TYPE &&
|
||||
document.filename === 'skills' &&
|
||||
document.parentId === null,
|
||||
);
|
||||
|
||||
export const getSkillFolder = (
|
||||
export const getSkillBundle = (
|
||||
documents: AgentDocument[],
|
||||
namespace: 'agent',
|
||||
skillName: string,
|
||||
) => {
|
||||
const root = getNamespaceRoot(documents, namespace);
|
||||
if (!root) return undefined;
|
||||
|
||||
return getScopedSkillDocuments(documents, namespace).find(
|
||||
(document) =>
|
||||
document.fileType === DOCUMENT_FOLDER_TYPE &&
|
||||
document.fileType === SKILL_BUNDLE_FILE_TYPE &&
|
||||
document.filename === skillName &&
|
||||
document.parentId === root.documentId,
|
||||
document.parentId === null,
|
||||
);
|
||||
};
|
||||
|
||||
export const getSkillFile = (documents: AgentDocument[], namespace: 'agent', skillName: string) => {
|
||||
const folder = getSkillFolder(documents, namespace, skillName);
|
||||
if (!folder) return undefined;
|
||||
const bundle = getSkillBundle(documents, namespace, skillName);
|
||||
if (!bundle) return undefined;
|
||||
|
||||
return getScopedSkillDocuments(documents, namespace).find(
|
||||
(document) => document.filename === SKILL_FILE_NAME && document.parentId === folder.documentId,
|
||||
);
|
||||
};
|
||||
|
||||
export const listScopedSkillFolders = (documents: AgentDocument[], namespace: 'agent') => {
|
||||
const root = getNamespaceRoot(documents, namespace);
|
||||
if (!root) return [];
|
||||
|
||||
return getScopedSkillDocuments(documents, namespace).filter(
|
||||
(document) =>
|
||||
document.fileType === DOCUMENT_FOLDER_TYPE && document.parentId === root.documentId,
|
||||
document.fileType === SKILL_INDEX_FILE_TYPE &&
|
||||
document.filename === SKILL_FILE_NAME &&
|
||||
document.parentId === bundle.documentId,
|
||||
);
|
||||
};
|
||||
|
||||
export const listScopedSkillBundles = (documents: AgentDocument[], namespace: 'agent') =>
|
||||
getScopedSkillDocuments(documents, namespace).filter(
|
||||
(document) => document.fileType === SKILL_BUNDLE_FILE_TYPE && document.parentId === null,
|
||||
);
|
||||
|
||||
export const assertSkillDocument = <T>(document: T | undefined, message = 'Skill not found') => {
|
||||
if (!document) {
|
||||
throw new AgentDocumentVfsError(message, 'NOT_FOUND');
|
||||
@@ -212,33 +210,6 @@ export const assertSkillDocument = <T>(document: T | undefined, message = 'Skill
|
||||
return document;
|
||||
};
|
||||
|
||||
export const ensureNamespaceRoot = async ({
|
||||
agentId,
|
||||
agentDocumentModel,
|
||||
namespace,
|
||||
}: {
|
||||
agentDocumentModel: AgentSkillDocumentModelLike;
|
||||
agentId: string;
|
||||
namespace: 'agent';
|
||||
}): Promise<{ documentId: string }> => {
|
||||
const documents = await agentDocumentModel.findByAgent(agentId);
|
||||
const existingRoot = getNamespaceRoot(documents, namespace);
|
||||
|
||||
if (existingRoot) {
|
||||
return { documentId: existingRoot.documentId };
|
||||
}
|
||||
|
||||
const root = await agentDocumentModel.create(agentId, 'skills', '', {
|
||||
editorData: EMPTY_EDITOR_DATA,
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: 'skills',
|
||||
});
|
||||
|
||||
return { documentId: root.documentId };
|
||||
};
|
||||
|
||||
export const createSkillTree = async ({
|
||||
agentDocumentModel,
|
||||
agentId,
|
||||
@@ -248,70 +219,46 @@ export const createSkillTree = async ({
|
||||
skillName,
|
||||
}: CreateSkillTreeInput) => {
|
||||
const existingDocuments = await agentDocumentModel.findByAgent(agentId);
|
||||
const existingRoot = getNamespaceRoot(existingDocuments, namespace);
|
||||
const existingFolder = getSkillFolder(existingDocuments, namespace, skillName);
|
||||
const existingFolder = getSkillBundle(existingDocuments, namespace, skillName);
|
||||
const existingFile = getSkillFile(existingDocuments, namespace, skillName);
|
||||
|
||||
if (existingFolder || existingFile) {
|
||||
throw new AgentDocumentVfsError('Skill already exists', 'CONFLICT');
|
||||
}
|
||||
|
||||
const root = existingRoot
|
||||
? { documentId: existingRoot.documentId }
|
||||
: await ensureNamespaceRoot({
|
||||
agentDocumentModel,
|
||||
agentId,
|
||||
namespace,
|
||||
});
|
||||
// NOTICE:
|
||||
// This path is used by direct Agent Document VFS writes, including `lb agent space fs`.
|
||||
// It creates skill-shaped bundle/index documents for filesystem compatibility only.
|
||||
// These documents are not authored through SkillManagementDocumentService and should not be
|
||||
// assumed to be fully recognized as managed Agent Signal skills until that service supports
|
||||
// importing or normalizing VFS-created skill-shaped documents.
|
||||
// Removal condition: delete this once VFS create/update routes through the skill-management
|
||||
// service or that service explicitly supports this compatibility document shape.
|
||||
const metadata = { skill: { vfs: true } };
|
||||
const bundle = await agentDocumentModel.create(agentId, skillName, '', {
|
||||
editorData: EMPTY_EDITOR_DATA,
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
metadata,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: SKILL_MANAGEMENT_SOURCE,
|
||||
sourceType: SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: skillName,
|
||||
});
|
||||
|
||||
const createdRootId: string | undefined = existingRoot ? undefined : root.documentId;
|
||||
let createdFolderId: string | undefined;
|
||||
let createdFileId: string | undefined;
|
||||
const file = await agentDocumentModel.create(agentId, SKILL_FILE_NAME, content, {
|
||||
editorData,
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
metadata,
|
||||
parentId: bundle.documentId,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: SKILL_MANAGEMENT_SOURCE,
|
||||
sourceType: SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: SKILL_FILE_NAME,
|
||||
});
|
||||
|
||||
try {
|
||||
const folder = await agentDocumentModel.create(agentId, skillName, '', {
|
||||
editorData: EMPTY_EDITOR_DATA,
|
||||
fileType: DOCUMENT_FOLDER_TYPE,
|
||||
parentId: root.documentId,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: skillName,
|
||||
});
|
||||
|
||||
createdFolderId = folder.id;
|
||||
|
||||
const file = await agentDocumentModel.create(agentId, SKILL_FILE_NAME, content, {
|
||||
editorData,
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
parentId: folder.documentId,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: SKILL_FILE_NAME,
|
||||
});
|
||||
|
||||
createdFileId = file.id;
|
||||
|
||||
return { fileDocumentId: file.documentId, folderDocumentId: folder.documentId };
|
||||
} catch (error) {
|
||||
if (createdFileId) {
|
||||
await agentDocumentModel.delete(createdFileId, 'skill-create-rollback');
|
||||
}
|
||||
|
||||
if (createdFolderId) {
|
||||
await agentDocumentModel.delete(createdFolderId, 'skill-create-rollback');
|
||||
}
|
||||
|
||||
if (createdRootId) {
|
||||
const rootBinding = await agentDocumentModel
|
||||
.findByAgent(agentId)
|
||||
.then((documents) => documents.find((document) => document.documentId === createdRootId));
|
||||
|
||||
if (rootBinding) {
|
||||
await agentDocumentModel.delete(rootBinding.id, 'skill-create-rollback');
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
return { fileDocumentId: file.documentId, folderDocumentId: bundle.documentId };
|
||||
};
|
||||
|
||||
export const sortSkillFolders = (documents: AgentDocument[]) =>
|
||||
|
||||
@@ -179,6 +179,30 @@ describe('AgentDocumentsService', () => {
|
||||
title: 'My Title',
|
||||
});
|
||||
});
|
||||
|
||||
it('persists agent signal skill hints in document metadata', async () => {
|
||||
mockModel.findByFilename.mockResolvedValue(undefined);
|
||||
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'Reusable Procedure' });
|
||||
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
await service.createDocument('agent-1', 'Reusable Procedure', 'content', {
|
||||
hintIsSkill: true,
|
||||
});
|
||||
|
||||
expect(mockModel.create).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
expect.any(String),
|
||||
'content',
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
agentSignal: {
|
||||
hintedByTool: 'lobe-agent-documents.createDocument',
|
||||
hintIsSkill: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createForTopic', () => {
|
||||
|
||||
@@ -47,6 +47,10 @@ interface UpsertDocumentParams {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
interface CreateAgentDocumentOptions {
|
||||
hintIsSkill?: boolean;
|
||||
}
|
||||
|
||||
type AgentDocumentWithLiteXML = AgentDocument & { litexml?: string };
|
||||
|
||||
/**
|
||||
@@ -132,7 +136,7 @@ export class AgentDocumentsService {
|
||||
params?: {
|
||||
loadPosition?: DocumentLoadPosition;
|
||||
loadRules?: DocumentLoadRules;
|
||||
metadata?: Record<string, any>;
|
||||
metadata?: Record<string, unknown>;
|
||||
policy?: AgentDocumentPolicy;
|
||||
templateId?: string;
|
||||
},
|
||||
@@ -304,14 +308,39 @@ export class AgentDocumentsService {
|
||||
return this.agentDocumentModel.associate({ agentId, documentId });
|
||||
}
|
||||
|
||||
async createDocument(agentId: string, title: string, content: string) {
|
||||
async createDocument(
|
||||
agentId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
options: CreateAgentDocumentOptions = {},
|
||||
) {
|
||||
const { title: extractedTitle, content: strippedContent } = extractMarkdownH1Title(content);
|
||||
const finalTitle = extractedTitle || title;
|
||||
return this.createWithUniqueFilename(agentId, finalTitle, strippedContent);
|
||||
const metadata = options.hintIsSkill
|
||||
? {
|
||||
agentSignal: {
|
||||
hintedByTool: 'lobe-agent-documents.createDocument',
|
||||
hintIsSkill: true,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return this.createWithUniqueFilename(
|
||||
agentId,
|
||||
finalTitle,
|
||||
strippedContent,
|
||||
metadata ? { metadata } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async createForTopic(agentId: string, title: string, content: string, topicId: string) {
|
||||
const doc = await this.createDocument(agentId, title, content);
|
||||
async createForTopic(
|
||||
agentId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
topicId: string,
|
||||
options: CreateAgentDocumentOptions = {},
|
||||
) {
|
||||
const doc = await this.createDocument(agentId, title, content, options);
|
||||
|
||||
await this.topicDocumentModel.associate({
|
||||
documentId: doc.documentId,
|
||||
@@ -447,6 +476,7 @@ export class AgentDocumentsService {
|
||||
filename: d.filename,
|
||||
id: d.id,
|
||||
loadPosition: d.policy?.context?.position,
|
||||
parentId: d.parentId,
|
||||
title: d.title,
|
||||
}));
|
||||
}
|
||||
@@ -466,6 +496,7 @@ export class AgentDocumentsService {
|
||||
filename: doc.filename,
|
||||
id: doc.id,
|
||||
loadPosition: doc.policy?.context?.position,
|
||||
parentId: doc.parentId,
|
||||
title: doc.title,
|
||||
}));
|
||||
}
|
||||
|
||||
+2
-1
@@ -231,7 +231,7 @@ describe('feedbackDomainJudge', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes structured satisfaction output to the resolver without serialized context', async () => {
|
||||
it('passes structured satisfaction output and serialized context to the resolver', async () => {
|
||||
let resolverInput:
|
||||
| Parameters<
|
||||
NonNullable<
|
||||
@@ -311,6 +311,7 @@ describe('feedbackDomainJudge', () => {
|
||||
messageId: 'msg_structured',
|
||||
reason: 'corrective-feedback-cue',
|
||||
result: 'not_satisfied',
|
||||
serializedContext: '{"large":"context"}',
|
||||
},
|
||||
source: { sourceId: 'source_structured', sourceType: 'agent.user.message' },
|
||||
sourceHints: { intents: ['memory'] },
|
||||
|
||||
+44
-66
@@ -3,10 +3,8 @@ import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AgentDocumentModel } from '@/database/models/agentDocuments';
|
||||
import { createMarkdownEditorSnapshot } from '@/server/services/agentDocuments/headlessEditor';
|
||||
import { AgentDocumentVfsService } from '@/server/services/agentDocumentVfs';
|
||||
import { createSkillTree } from '@/server/services/agentDocumentVfs/mounts/skills/providers/providerSkillsAgentDocumentUtils';
|
||||
import { SkillManagementDocumentService } from '@/server/services/skillManagement';
|
||||
|
||||
import {
|
||||
cleanupTestUser,
|
||||
@@ -38,16 +36,13 @@ describe('runSkillManagementAction integration', () => {
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
});
|
||||
|
||||
const createManagedSkill = async (skillName: string, content: string) => {
|
||||
const snapshot = await createMarkdownEditorSnapshot(content);
|
||||
|
||||
await createSkillTree({
|
||||
agentDocumentModel: new AgentDocumentModel(serverDB, userId),
|
||||
const createManagedSkill = async (skillName: string, bodyMarkdown: string) => {
|
||||
return new SkillManagementDocumentService(serverDB, userId).createSkill({
|
||||
agentId,
|
||||
content: snapshot.content,
|
||||
editorData: snapshot.editorData,
|
||||
namespace: 'agent',
|
||||
skillName,
|
||||
bodyMarkdown,
|
||||
description: `${skillName} description`,
|
||||
name: skillName,
|
||||
title: skillName,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -62,10 +57,10 @@ describe('runSkillManagementAction integration', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Refine reads and writes the selected managed skill through the real resolver and VFS adapter.
|
||||
* Refine reads and writes the selected managed skill through the document-backed service.
|
||||
*/
|
||||
it('refines a real managed skill through resolver and VFS-backed maintainer operations', async () => {
|
||||
await createManagedSkill('review-skill', '# Review Skill');
|
||||
it('refines a real managed skill through document-backed replacement', async () => {
|
||||
const skill = await createManagedSkill('review-skill', '# Review Skill');
|
||||
|
||||
const result = await runSkillManagementAction(
|
||||
{
|
||||
@@ -77,24 +72,16 @@ describe('runSkillManagementAction integration', () => {
|
||||
selfIterationEnabled: true,
|
||||
skillMaintainerRunner: async ({ targetSkills }) => {
|
||||
expect(targetSkills).toEqual([
|
||||
{
|
||||
content: '# Review Skill\n',
|
||||
id: 'review-skill',
|
||||
metadata: {},
|
||||
},
|
||||
expect.objectContaining({
|
||||
content:
|
||||
'---\ndescription: review-skill description\nname: review-skill\n---\n# Review Skill',
|
||||
id: skill.bundle.agentDocumentId,
|
||||
name: 'review-skill',
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
operations: [
|
||||
{
|
||||
arguments: {
|
||||
content: '# Review Skill\n\n## Procedure\n- Check failed assertions first.',
|
||||
path: 'SKILL.md',
|
||||
skillRef: 'review-skill',
|
||||
},
|
||||
name: 'updateSkill',
|
||||
},
|
||||
],
|
||||
bodyMarkdown: '# Review Skill\n\n## Procedure\n- Check failed assertions first.',
|
||||
reason: 'refined review skill',
|
||||
};
|
||||
},
|
||||
@@ -103,7 +90,7 @@ describe('runSkillManagementAction integration', () => {
|
||||
{
|
||||
action: 'refine',
|
||||
reason: 'update existing review skill',
|
||||
targetSkillIds: ['review-skill'],
|
||||
targetSkillRefs: [skill.bundle.agentDocumentId],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -112,17 +99,17 @@ describe('runSkillManagementAction integration', () => {
|
||||
status: 'applied',
|
||||
});
|
||||
expect(await readSkillIndex('review-skill')).toBe(
|
||||
'# Review Skill\n\n## Procedure\n\n- Check failed assertions first.\n',
|
||||
'---\ndescription: review-skill description\nname: review-skill\n---\n# Review Skill\n\n## Procedure\n- Check failed assertions first.',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Consolidate updates an allowed target skill and does not apply lifecycle proposals.
|
||||
* Consolidate updates an allowed target skill and leaves other skills untouched.
|
||||
*/
|
||||
it('consolidates real managed skills without applying lifecycle proposals automatically', async () => {
|
||||
await createManagedSkill('review-skill', '# Review Skill');
|
||||
await createManagedSkill('review-checklist', '# Review Checklist');
|
||||
it('consolidates real managed skills without deleting source skills automatically', async () => {
|
||||
const reviewSkill = await createManagedSkill('review-skill', '# Review Skill');
|
||||
const checklistSkill = await createManagedSkill('review-checklist', '# Review Checklist');
|
||||
|
||||
const result = await runSkillManagementAction(
|
||||
{
|
||||
@@ -134,36 +121,22 @@ describe('runSkillManagementAction integration', () => {
|
||||
selfIterationEnabled: true,
|
||||
skillMaintainerRunner: async ({ targetSkills }) => {
|
||||
expect(targetSkills).toEqual([
|
||||
{
|
||||
content: '# Review Skill\n',
|
||||
id: 'review-skill',
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
content: '# Review Checklist\n',
|
||||
id: 'review-checklist',
|
||||
metadata: {},
|
||||
},
|
||||
expect.objectContaining({
|
||||
content:
|
||||
'---\ndescription: review-skill description\nname: review-skill\n---\n# Review Skill',
|
||||
id: reviewSkill.bundle.agentDocumentId,
|
||||
name: 'review-skill',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
content:
|
||||
'---\ndescription: review-checklist description\nname: review-checklist\n---\n# Review Checklist',
|
||||
id: checklistSkill.bundle.agentDocumentId,
|
||||
name: 'review-checklist',
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
operations: [
|
||||
{
|
||||
arguments: {
|
||||
content: '# Review Skill\n\n## Procedure\n- Use one consolidated checklist.',
|
||||
path: 'SKILL.md',
|
||||
skillRef: 'review-skill',
|
||||
},
|
||||
name: 'updateSkill',
|
||||
},
|
||||
],
|
||||
proposedLifecycleActions: [
|
||||
{
|
||||
action: 'archive',
|
||||
reason: 'merged into review-skill',
|
||||
skillRef: 'review-checklist',
|
||||
},
|
||||
],
|
||||
bodyMarkdown: '# Review Skill\n\n## Procedure\n- Use one consolidated checklist.',
|
||||
reason: 'consolidated review skills',
|
||||
};
|
||||
},
|
||||
@@ -172,7 +145,10 @@ describe('runSkillManagementAction integration', () => {
|
||||
{
|
||||
action: 'consolidate',
|
||||
reason: 'overlapping review skills',
|
||||
targetSkillIds: ['review-skill', 'review-checklist'],
|
||||
targetSkillRefs: [
|
||||
reviewSkill.bundle.agentDocumentId,
|
||||
checklistSkill.bundle.agentDocumentId,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -181,8 +157,10 @@ describe('runSkillManagementAction integration', () => {
|
||||
status: 'applied',
|
||||
});
|
||||
expect(await readSkillIndex('review-skill')).toBe(
|
||||
'# Review Skill\n\n## Procedure\n\n- Use one consolidated checklist.\n',
|
||||
'---\ndescription: review-skill description\nname: review-skill\n---\n# Review Skill\n\n## Procedure\n- Use one consolidated checklist.',
|
||||
);
|
||||
expect(await readSkillIndex('review-checklist')).toBe(
|
||||
'---\ndescription: review-checklist description\nname: review-checklist\n---\n# Review Checklist',
|
||||
);
|
||||
expect(await readSkillIndex('review-checklist')).toBe('# Review Checklist\n');
|
||||
});
|
||||
});
|
||||
|
||||
+135
-123
@@ -2,25 +2,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type * as ProviderSkillsAgentDocumentUtils from '@/server/services/agentDocumentVfs/mounts/skills/providers/providerSkillsAgentDocumentUtils';
|
||||
import { createSkillTree } from '@/server/services/agentDocumentVfs/mounts/skills/providers/providerSkillsAgentDocumentUtils';
|
||||
import type { SkillManagementDocumentService } from '@/server/services/skillManagement';
|
||||
|
||||
import type { RuntimeProcessorContext } from '../../../../runtime/context';
|
||||
import {
|
||||
collectAgentSkillDecisionCandidates,
|
||||
defineSkillManagementActionHandler,
|
||||
handleSkillManagementSignal,
|
||||
isAgentDocumentRelatedObject,
|
||||
runSkillDecisionAgentRuntime,
|
||||
} from '../skillManagement';
|
||||
|
||||
const skillDecisionRunner = vi.fn();
|
||||
const skillCreateRunner = vi.fn();
|
||||
const skillMaintainerRunner = vi.fn();
|
||||
const skillMaintainerService = {
|
||||
readSkillFile: vi.fn(),
|
||||
removeSkillFile: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
writeSkillFile: vi.fn(),
|
||||
createSkill: vi.fn(),
|
||||
getSkill: vi.fn(),
|
||||
listSkills: vi.fn(),
|
||||
renameSkill: vi.fn(),
|
||||
replaceSkillIndex: vi.fn(),
|
||||
};
|
||||
const createSkill = vi.fn();
|
||||
|
||||
vi.mock('@/server/services/agentDocuments/headlessEditor', () => ({
|
||||
createMarkdownEditorSnapshot: vi.fn(async (content: string) => ({
|
||||
@@ -29,17 +32,20 @@ vi.mock('@/server/services/agentDocuments/headlessEditor', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
'@/server/services/agentDocumentVfs/mounts/skills/providers/providerSkillsAgentDocumentUtils',
|
||||
async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ProviderSkillsAgentDocumentUtils>();
|
||||
vi.mock('@/server/services/skillManagement', async (importOriginal) => {
|
||||
const actual = await importOriginal<SkillManagementDocumentService>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createSkillTree: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
SkillManagementDocumentService: vi.fn(() => ({
|
||||
createSkill,
|
||||
getSkill: skillMaintainerService.getSkill,
|
||||
listSkills: skillMaintainerService.listSkills,
|
||||
renameSkill: skillMaintainerService.renameSkill,
|
||||
replaceSkillIndex: skillMaintainerService.replaceSkillIndex,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const context = {
|
||||
now: () => 1,
|
||||
@@ -53,12 +59,44 @@ const context = {
|
||||
describe('defineSkillManagementActionHandler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
skillCreateRunner.mockReset();
|
||||
skillDecisionRunner.mockReset();
|
||||
skillMaintainerRunner.mockReset();
|
||||
skillMaintainerService.readSkillFile.mockReset();
|
||||
skillMaintainerService.removeSkillFile.mockReset();
|
||||
skillMaintainerService.updateSkill.mockReset();
|
||||
skillMaintainerService.writeSkillFile.mockReset();
|
||||
skillMaintainerService.createSkill.mockReset();
|
||||
skillMaintainerService.getSkill.mockReset();
|
||||
skillMaintainerService.listSkills.mockReset();
|
||||
skillMaintainerService.renameSkill.mockReset();
|
||||
skillMaintainerService.replaceSkillIndex.mockReset();
|
||||
createSkill.mockReset();
|
||||
skillCreateRunner.mockResolvedValue({
|
||||
bodyMarkdown: '# PR Review Checklist',
|
||||
confidence: 0.9,
|
||||
description: 'Use when creating reusable PR review checklists.',
|
||||
name: 'pr-review-checklist',
|
||||
reason: 'authored reusable workflow',
|
||||
title: 'PR Review Checklist',
|
||||
});
|
||||
skillMaintainerService.getSkill.mockImplementation(async ({ agentDocumentId }) => ({
|
||||
bundle: {
|
||||
agentDocumentId,
|
||||
documentId: `${agentDocumentId}-doc`,
|
||||
filename: agentDocumentId,
|
||||
title: agentDocumentId,
|
||||
},
|
||||
content: `# ${agentDocumentId}`,
|
||||
description: `${agentDocumentId} description`,
|
||||
frontmatter: { description: `${agentDocumentId} description`, name: agentDocumentId },
|
||||
index: {
|
||||
agentDocumentId: `${agentDocumentId}-index`,
|
||||
documentId: `${agentDocumentId}-index-doc`,
|
||||
filename: 'SKILL.md',
|
||||
title: 'SKILL.md',
|
||||
},
|
||||
name: agentDocumentId,
|
||||
title: agentDocumentId,
|
||||
}));
|
||||
skillMaintainerService.replaceSkillIndex.mockResolvedValue(undefined);
|
||||
skillMaintainerService.renameSkill.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('does not run when self iteration is disabled', async () => {
|
||||
@@ -134,7 +172,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
toolsCalling: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{"documentId":"doc_1"}',
|
||||
arguments: '{"agentDocumentId":"agent_doc_1"}',
|
||||
name: 'agent-signal-skill-decision____readDocument',
|
||||
},
|
||||
id: 'call_read_document',
|
||||
@@ -150,7 +188,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
{
|
||||
function: {
|
||||
arguments:
|
||||
'{"action":"reject","confidence":0.9,"documentRefs":["doc_1"],"reason":"The same turn created a document and forbids skill conversion.","requiredReads":[],"targetSkillIds":[]}',
|
||||
'{"action":"reject","confidence":0.9,"documentRefs":["doc_1"],"reason":"The same turn created a document and forbids skill conversion.","requiredReads":[],"targetSkillRefs":[]}',
|
||||
name: 'agent-signal-skill-decision____submitDecision',
|
||||
},
|
||||
id: 'call_submit_decision',
|
||||
@@ -164,14 +202,15 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
listCandidateDocuments: vi.fn(),
|
||||
listSameTurnDocumentOutcomes: vi.fn().mockResolvedValue([
|
||||
{
|
||||
documentId: 'doc_1',
|
||||
agentDocumentId: 'agent_doc_1',
|
||||
relation: 'created',
|
||||
summary: 'Agent documents created a document.',
|
||||
},
|
||||
]),
|
||||
readDocument: vi.fn().mockResolvedValue({
|
||||
agentDocumentId: 'agent_doc_1',
|
||||
content: '# Draft',
|
||||
documentId: 'doc_1',
|
||||
documentId: 'documents_row_1',
|
||||
title: 'Draft',
|
||||
}),
|
||||
};
|
||||
@@ -195,7 +234,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
scopeKey: 'topic:topic_1',
|
||||
topicId: 'topic_1',
|
||||
});
|
||||
expect(tools.readDocument).toHaveBeenCalledWith({ documentId: 'doc_1' });
|
||||
expect(tools.readDocument).toHaveBeenCalledWith({ agentDocumentId: 'agent_doc_1' });
|
||||
expect(result).toMatchObject({
|
||||
action: 'reject',
|
||||
documentRefs: ['doc_1'],
|
||||
@@ -203,6 +242,16 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Same-turn document receipts emitted by lobe-agent-documents use the agent document binding id.
|
||||
*/
|
||||
it('accepts agent-document related objects for same-turn document evidence', () => {
|
||||
expect(isAgentDocumentRelatedObject({ objectType: 'agent-document' })).toBe(true);
|
||||
expect(isAgentDocumentRelatedObject({ objectType: 'document' })).toBe(false);
|
||||
expect(isAgentDocumentRelatedObject({ objectType: 'file' })).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Removed lifecycle tools must not leak into the action result.
|
||||
@@ -227,39 +276,31 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Candidate ids are managed package names, while names remain display labels.
|
||||
* Candidate ids are managed bundle agent document ids, while names remain display labels.
|
||||
*/
|
||||
it('collects managed skill folders as package-name decision candidates', () => {
|
||||
it('collects managed skill bundles as agent-document decision candidates', () => {
|
||||
expect(
|
||||
collectAgentSkillDecisionCandidates([
|
||||
{
|
||||
documentId: 'folder-doc',
|
||||
fileType: 'custom/folder',
|
||||
documentId: 'bundle-doc',
|
||||
fileType: 'skills/bundle',
|
||||
filename: 'review-skill',
|
||||
id: 'folder-binding',
|
||||
parentId: 'root-doc',
|
||||
id: 'bundle-binding',
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
title: 'Review Skill',
|
||||
},
|
||||
{
|
||||
documentId: 'root-doc',
|
||||
fileType: 'custom/folder',
|
||||
filename: 'skills',
|
||||
id: 'root-binding',
|
||||
parentId: null,
|
||||
templateId: 'agent-skill',
|
||||
title: 'skills',
|
||||
},
|
||||
{
|
||||
documentId: 'file-doc',
|
||||
fileType: 'skills/index',
|
||||
filename: 'SKILL.md',
|
||||
id: 'file-binding',
|
||||
parentId: 'folder-doc',
|
||||
parentId: 'bundle-doc',
|
||||
templateId: 'agent-skill',
|
||||
title: 'SKILL.md',
|
||||
},
|
||||
] as never),
|
||||
).toEqual([{ id: 'review-skill', name: 'Review Skill', scope: 'agent' }]);
|
||||
).toEqual([{ id: 'bundle-binding', name: 'Review Skill', scope: 'agent' }]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -276,6 +317,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
const handler = defineSkillManagementActionHandler({
|
||||
db: {} as never,
|
||||
selfIterationEnabled: true,
|
||||
skillCreateRunner,
|
||||
skillDecisionRunner,
|
||||
userId: 'user_1',
|
||||
});
|
||||
@@ -325,27 +367,29 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Decision agents receive managed skill package candidates so targetSkillIds can be stable ids.
|
||||
* Decision agents receive managed skill candidates so targetSkillRefs can be stable ids.
|
||||
*/
|
||||
it('passes discovered candidate skills into the decision step', async () => {
|
||||
skillDecisionRunner.mockResolvedValue({
|
||||
action: 'refine',
|
||||
confidence: 0.8,
|
||||
reason: 'update existing skill',
|
||||
targetSkillIds: ['review-skill'],
|
||||
targetSkillRefs: ['review-skill-bundle-id'],
|
||||
});
|
||||
skillMaintainerRunner.mockResolvedValue({
|
||||
bodyMarkdown: '# Review Skill',
|
||||
reason: 'no file changes',
|
||||
});
|
||||
skillMaintainerService.readSkillFile.mockResolvedValue('# Review Skill');
|
||||
skillMaintainerRunner.mockResolvedValue({ operations: [], reason: 'no file changes' });
|
||||
|
||||
const handler = defineSkillManagementActionHandler({
|
||||
db: {} as never,
|
||||
selfIterationEnabled: true,
|
||||
skillCandidateSkillsFactory: async () => [
|
||||
{ id: 'review-skill', name: 'Review Skill', scope: 'agent' },
|
||||
{ id: 'review-skill-bundle-id', name: 'Review Skill', scope: 'agent' },
|
||||
],
|
||||
skillDecisionRunner,
|
||||
skillMaintainerRunner,
|
||||
skillMaintainerServiceFactory: () => skillMaintainerService,
|
||||
skillManagementServiceFactory: () => skillMaintainerService,
|
||||
userId: 'user_1',
|
||||
});
|
||||
|
||||
@@ -371,7 +415,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
|
||||
expect(skillDecisionRunner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
candidateSkills: [{ id: 'review-skill', name: 'Review Skill', scope: 'agent' }],
|
||||
candidateSkills: [{ id: 'review-skill-bundle-id', name: 'Review Skill', scope: 'agent' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -509,7 +553,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
context,
|
||||
);
|
||||
|
||||
expect(createSkillTree).not.toHaveBeenCalled();
|
||||
expect(createSkill).not.toHaveBeenCalled();
|
||||
expect(context.runtimeState.touchGuardState).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
detail: 'decision output was not an object',
|
||||
@@ -566,7 +610,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
context,
|
||||
);
|
||||
|
||||
expect(createSkillTree).not.toHaveBeenCalled();
|
||||
expect(createSkill).not.toHaveBeenCalled();
|
||||
expect(context.runtimeState.touchGuardState).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
detail: 'decision structured output was malformed',
|
||||
@@ -610,7 +654,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
);
|
||||
|
||||
expect(skillDecisionRunner).not.toHaveBeenCalled();
|
||||
expect(createSkillTree).not.toHaveBeenCalled();
|
||||
expect(createSkill).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
detail: 'self iteration is disabled',
|
||||
output: { decision: { action: 'noop' } },
|
||||
@@ -657,7 +701,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
status: 'skipped',
|
||||
});
|
||||
expect(skillDecisionRunner).not.toHaveBeenCalled();
|
||||
expect(createSkillTree).not.toHaveBeenCalled();
|
||||
expect(createSkill).not.toHaveBeenCalled();
|
||||
expect(skillMaintainerRunner).not.toHaveBeenCalled();
|
||||
|
||||
vi.clearAllMocks();
|
||||
@@ -692,26 +736,16 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* A refine decision invokes the maintainer agent and applies returned file operations.
|
||||
* A refine decision invokes the maintainer agent and applies returned body content.
|
||||
*/
|
||||
it('runs the maintainer workflow for refine decisions', async () => {
|
||||
skillDecisionRunner.mockResolvedValue({
|
||||
action: 'refine',
|
||||
reason: 'update existing review skill',
|
||||
targetSkillIds: ['review-skill'],
|
||||
targetSkillRefs: ['review-skill-bundle-id'],
|
||||
});
|
||||
skillMaintainerService.readSkillFile.mockResolvedValue('# Review Skill');
|
||||
skillMaintainerRunner.mockResolvedValue({
|
||||
operations: [
|
||||
{
|
||||
arguments: {
|
||||
content: '# Review Skill\n\n## Procedure\n- Check failed assertions first.',
|
||||
path: 'SKILL.md',
|
||||
skillRef: 'review-skill',
|
||||
},
|
||||
name: 'updateSkill',
|
||||
},
|
||||
],
|
||||
bodyMarkdown: '# Review Skill\n\n## Procedure\n- Check failed assertions first.',
|
||||
reason: 'refined review skill',
|
||||
});
|
||||
|
||||
@@ -720,7 +754,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
selfIterationEnabled: true,
|
||||
skillDecisionRunner,
|
||||
skillMaintainerRunner,
|
||||
skillMaintainerServiceFactory: () => skillMaintainerService,
|
||||
skillManagementServiceFactory: () => skillMaintainerService,
|
||||
userId: 'user_1',
|
||||
});
|
||||
|
||||
@@ -746,14 +780,22 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
|
||||
expect(skillMaintainerRunner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetSkills: [{ content: '# Review Skill', id: 'review-skill', metadata: {} }],
|
||||
targetSkills: [
|
||||
expect.objectContaining({
|
||||
content: '# review-skill-bundle-id',
|
||||
id: 'review-skill-bundle-id',
|
||||
name: 'review-skill-bundle-id',
|
||||
}),
|
||||
],
|
||||
type: 'refine',
|
||||
}),
|
||||
);
|
||||
expect(skillMaintainerService.updateSkill).toHaveBeenCalledWith({
|
||||
content: '# Review Skill\n\n## Procedure\n- Check failed assertions first.',
|
||||
path: 'SKILL.md',
|
||||
skillRef: 'review-skill',
|
||||
expect(skillMaintainerService.replaceSkillIndex).toHaveBeenCalledWith({
|
||||
agentDocumentId: 'review-skill-bundle-id',
|
||||
agentId: 'agent_1',
|
||||
bodyMarkdown: '# Review Skill\n\n## Procedure\n- Check failed assertions first.',
|
||||
description: undefined,
|
||||
updateReason: 'refined review skill',
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
detail: 'refined review skill',
|
||||
@@ -764,35 +806,22 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* A maintainer operation naming a non-target skill is skipped before mutation.
|
||||
* A refine decision is skipped when the target skill cannot be resolved.
|
||||
*/
|
||||
it('skips maintainer operations that target skills outside the decision target set', async () => {
|
||||
it('skips maintainer workflow when target refs cannot be resolved', async () => {
|
||||
skillDecisionRunner.mockResolvedValue({
|
||||
action: 'refine',
|
||||
reason: 'update existing review skill',
|
||||
targetSkillIds: ['review-skill'],
|
||||
});
|
||||
skillMaintainerService.readSkillFile.mockResolvedValue('# Review Skill');
|
||||
skillMaintainerRunner.mockResolvedValue({
|
||||
operations: [
|
||||
{
|
||||
arguments: {
|
||||
content: '# Other Skill',
|
||||
path: 'SKILL.md',
|
||||
skillRef: 'other-skill',
|
||||
},
|
||||
name: 'updateSkill',
|
||||
},
|
||||
],
|
||||
reason: 'attempted cross-target write',
|
||||
targetSkillRefs: ['missing-bundle-id'],
|
||||
});
|
||||
skillMaintainerService.getSkill.mockResolvedValueOnce(undefined);
|
||||
|
||||
const handler = defineSkillManagementActionHandler({
|
||||
db: {} as never,
|
||||
selfIterationEnabled: true,
|
||||
skillDecisionRunner,
|
||||
skillMaintainerRunner,
|
||||
skillMaintainerServiceFactory: () => skillMaintainerService,
|
||||
skillManagementServiceFactory: () => skillMaintainerService,
|
||||
userId: 'user_1',
|
||||
});
|
||||
|
||||
@@ -816,11 +845,10 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
context,
|
||||
);
|
||||
|
||||
expect(skillMaintainerService.updateSkill).not.toHaveBeenCalled();
|
||||
expect(skillMaintainerService.writeSkillFile).not.toHaveBeenCalled();
|
||||
expect(skillMaintainerService.removeSkillFile).not.toHaveBeenCalled();
|
||||
expect(skillMaintainerRunner).not.toHaveBeenCalled();
|
||||
expect(skillMaintainerService.replaceSkillIndex).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
detail: expect.stringContaining('other-skill'),
|
||||
detail: expect.stringContaining('could not resolve targetSkillRefs'),
|
||||
output: { decision: { action: 'refine' } },
|
||||
status: 'skipped',
|
||||
});
|
||||
@@ -834,29 +862,10 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
skillDecisionRunner.mockResolvedValue({
|
||||
action: 'consolidate',
|
||||
reason: 'overlapping review skills',
|
||||
targetSkillIds: ['review-skill', 'review-checklist'],
|
||||
targetSkillRefs: ['review-skill-bundle-id', 'review-checklist-bundle-id'],
|
||||
});
|
||||
skillMaintainerService.readSkillFile
|
||||
.mockResolvedValueOnce('# Review Skill')
|
||||
.mockResolvedValueOnce('# Review Checklist');
|
||||
skillMaintainerRunner.mockResolvedValue({
|
||||
operations: [
|
||||
{
|
||||
arguments: {
|
||||
content: '# Review Skill\n\n## Procedure\n- Use one consolidated checklist.',
|
||||
path: 'SKILL.md',
|
||||
skillRef: 'review-skill',
|
||||
},
|
||||
name: 'updateSkill',
|
||||
},
|
||||
],
|
||||
proposedLifecycleActions: [
|
||||
{
|
||||
action: 'archive',
|
||||
reason: 'merged into review-skill',
|
||||
skillRef: 'review-checklist',
|
||||
},
|
||||
],
|
||||
bodyMarkdown: '# Review Skill\n\n## Procedure\n- Use one consolidated checklist.',
|
||||
reason: 'consolidated review skills',
|
||||
});
|
||||
|
||||
@@ -865,7 +874,7 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
selfIterationEnabled: true,
|
||||
skillDecisionRunner,
|
||||
skillMaintainerRunner,
|
||||
skillMaintainerServiceFactory: () => skillMaintainerService,
|
||||
skillManagementServiceFactory: () => skillMaintainerService,
|
||||
userId: 'user_1',
|
||||
});
|
||||
|
||||
@@ -892,16 +901,18 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
expect(skillMaintainerRunner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetSkills: [
|
||||
{ content: '# Review Skill', id: 'review-skill', metadata: {} },
|
||||
{ content: '# Review Checklist', id: 'review-checklist', metadata: {} },
|
||||
expect.objectContaining({ id: 'review-skill-bundle-id' }),
|
||||
expect.objectContaining({ id: 'review-checklist-bundle-id' }),
|
||||
],
|
||||
type: 'consolidate',
|
||||
}),
|
||||
);
|
||||
expect(skillMaintainerService.updateSkill).toHaveBeenCalledWith({
|
||||
content: '# Review Skill\n\n## Procedure\n- Use one consolidated checklist.',
|
||||
path: 'SKILL.md',
|
||||
skillRef: 'review-skill',
|
||||
expect(skillMaintainerService.replaceSkillIndex).toHaveBeenCalledWith({
|
||||
agentDocumentId: 'review-skill-bundle-id',
|
||||
agentId: 'agent_1',
|
||||
bodyMarkdown: '# Review Skill\n\n## Procedure\n- Use one consolidated checklist.',
|
||||
description: undefined,
|
||||
updateReason: 'consolidated review skills',
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
detail: 'consolidated review skills',
|
||||
@@ -915,12 +926,13 @@ describe('defineSkillManagementActionHandler', () => {
|
||||
* Duplicate skill creation is reported as skipped while preserving the create decision.
|
||||
*/
|
||||
it('skips duplicate skill creation with a structured create decision', async () => {
|
||||
vi.mocked(createSkillTree).mockRejectedValueOnce(new Error('Skill already exists'));
|
||||
createSkill.mockRejectedValueOnce(new Error('Skill already exists'));
|
||||
skillDecisionRunner.mockResolvedValue({ action: 'create', reason: 'reusable workflow' });
|
||||
|
||||
const handler = defineSkillManagementActionHandler({
|
||||
db: {} as never,
|
||||
selfIterationEnabled: true,
|
||||
skillCreateRunner,
|
||||
skillDecisionRunner,
|
||||
userId: 'user_1',
|
||||
});
|
||||
|
||||
+368
-291
@@ -24,9 +24,11 @@ import type {
|
||||
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
|
||||
import {
|
||||
AGENT_SKILL_CONSOLIDATE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_CREATE_SYSTEM_ROLE,
|
||||
AGENT_SKILL_MANAGER_DECISION_SYSTEM_ROLE,
|
||||
AGENT_SKILL_REFINE_SYSTEM_ROLE,
|
||||
createAgentSkillConsolidatePrompt,
|
||||
createAgentSkillCreatePrompt,
|
||||
createAgentSkillManagerDecisionPrompt,
|
||||
createAgentSkillRefinePrompt,
|
||||
} from '@lobechat/prompts';
|
||||
@@ -39,17 +41,17 @@ import { AgentDocumentModel } from '@/database/models/agentDocuments';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
import { createMarkdownEditorSnapshot } from '@/server/services/agentDocuments/headlessEditor';
|
||||
import { AgentDocumentVfsService } from '@/server/services/agentDocumentVfs';
|
||||
import {
|
||||
createSkillTree,
|
||||
getSkillFolder,
|
||||
} from '@/server/services/agentDocumentVfs/mounts/skills/providers/providerSkillsAgentDocumentUtils';
|
||||
import { getSkillBundle } from '@/server/services/agentDocumentVfs/mounts/skills/providers/providerSkillsAgentDocumentUtils';
|
||||
import { AgentSignalProcedureInspector } from '@/server/services/agentSignal/procedure';
|
||||
import { redisPolicyStateStore } from '@/server/services/agentSignal/store/adapters/redis/policyStateStore';
|
||||
import { SkillMaintainerService } from '@/server/services/skillMaintainer/SkillMaintainerService';
|
||||
import { SkillReferenceResolver } from '@/server/services/skillMaintainer/SkillReferenceResolver';
|
||||
import { VfsSkillPackageAdapter } from '@/server/services/skillMaintainer/VfsSkillPackageAdapter';
|
||||
import { SkillManagementDocumentService } from '@/server/services/skillManagement';
|
||||
import type {
|
||||
CreateSkillInput,
|
||||
RenameSkillInput,
|
||||
ReplaceSkillIndexInput,
|
||||
SkillDetail,
|
||||
SkillSummary,
|
||||
} from '@/server/services/skillManagement/types';
|
||||
|
||||
import type { RuntimeProcessorContext } from '../../../runtime/context';
|
||||
import { defineActionHandler } from '../../../runtime/middleware';
|
||||
@@ -72,7 +74,7 @@ export interface SkillManagementSignalPayload {
|
||||
agentId: string;
|
||||
/** Optional candidate skills already identified by routing. */
|
||||
candidateSkillRefs?: string[];
|
||||
/** Existing skills the decision agent may target by package id. */
|
||||
/** Existing skills the decision agent may target by agent document id. */
|
||||
candidateSkills?: SkillManagementCandidateSkill[];
|
||||
/** Evidence extracted from the feedback message. */
|
||||
evidence?: Array<{ cue: string; excerpt: string }>;
|
||||
@@ -102,10 +104,10 @@ export interface SkillManagementDecision {
|
||||
documentRefs?: string[];
|
||||
/** Optional short explanation for observability. */
|
||||
reason?: string;
|
||||
/** Optional file paths that should be read before refinement or consolidation. */
|
||||
/** Optional read hints that should be inspected before refinement or consolidation. */
|
||||
requiredReads?: string[];
|
||||
/** Optional target skill identifiers selected by the decision model. */
|
||||
targetSkillIds?: string[];
|
||||
/** Optional target managed skill bundle agent document ids selected by the decision model. */
|
||||
targetSkillRefs?: string[];
|
||||
}
|
||||
|
||||
export interface SkillManagementActionResult {
|
||||
@@ -132,30 +134,31 @@ export interface SkillManagementActionHandlerOptions {
|
||||
skillCandidateSkillsFactory?: (input: {
|
||||
agentId: string;
|
||||
}) => Promise<SkillManagementCandidateSkill[]>;
|
||||
skillCreateRunner?: (input: SkillCreateAuthoringInput) => Promise<unknown>;
|
||||
skillDecisionModel?: SkillManagementAgentModelConfig;
|
||||
skillDecisionRunner?: (input: SkillManagementSignalPayload) => Promise<unknown>;
|
||||
skillMaintainerRunner?: (input: SkillMaintainerWorkflowInput) => Promise<unknown>;
|
||||
skillMaintainerServiceFactory?: (input: {
|
||||
agentId: string;
|
||||
}) => SkillMaintainerFileOperationService;
|
||||
skillManagementServiceFactory?: (input: { agentId: string }) => SkillManagementOperationService;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SkillDecisionDocumentOutcome {
|
||||
documentId: string;
|
||||
agentDocumentId: string;
|
||||
relation?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface SkillDecisionCandidateDocument {
|
||||
agentDocumentId: string;
|
||||
documentId: string;
|
||||
filename?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SkillDecisionDocumentSnapshot {
|
||||
agentDocumentId: string;
|
||||
content?: string;
|
||||
documentId: string;
|
||||
documentId?: string;
|
||||
filename?: string;
|
||||
title?: string;
|
||||
}
|
||||
@@ -171,19 +174,30 @@ export interface SkillDecisionToolset {
|
||||
scopeKey?: string;
|
||||
topicId?: string;
|
||||
}) => Promise<SkillDecisionDocumentOutcome[]>;
|
||||
readDocument: (input: { documentId: string }) => Promise<SkillDecisionDocumentSnapshot>;
|
||||
readDocument: (input: { agentDocumentId: string }) => Promise<SkillDecisionDocumentSnapshot>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a procedure related object references an agent document binding.
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal consumes same-turn document tool receipts.
|
||||
* - Producers emit `agent-document` receipts whose ids are stable agent document bindings.
|
||||
*
|
||||
* Expects:
|
||||
* - `agent-document` object ids are `agent_documents.id`.
|
||||
*
|
||||
* Returns:
|
||||
* - Whether the object can be read through the Agent Documents service by id.
|
||||
*/
|
||||
export const isAgentDocumentRelatedObject = (object: { objectType: string }) =>
|
||||
object.objectType === 'agent-document';
|
||||
|
||||
export interface SkillManagementAgentModelConfig {
|
||||
model: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface SkillMaintainerOperation {
|
||||
arguments: Record<string, unknown>;
|
||||
name: 'removeSkillFile' | 'updateSkill' | 'writeSkillFile';
|
||||
}
|
||||
|
||||
export interface SkillMaintainerWorkflowInput {
|
||||
decision: SkillManagementDecision;
|
||||
signal: SkillManagementActionInput;
|
||||
@@ -191,22 +205,50 @@ export interface SkillMaintainerWorkflowInput {
|
||||
content: string;
|
||||
id: string;
|
||||
metadata: Record<string, unknown>;
|
||||
name: string;
|
||||
}>;
|
||||
type: 'consolidate' | 'refine';
|
||||
}
|
||||
|
||||
export interface SkillMaintainerWorkflowResult {
|
||||
bodyMarkdown: string;
|
||||
confidence?: number;
|
||||
operations: SkillMaintainerOperation[];
|
||||
proposedLifecycleActions?: Array<Record<string, unknown>>;
|
||||
description?: string;
|
||||
reason?: string;
|
||||
rename?: {
|
||||
newName?: string;
|
||||
newTitle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillMaintainerFileOperationService {
|
||||
readSkillFile: (input: { path: string; skillRef: string }) => Promise<string>;
|
||||
removeSkillFile: (input: { path: string; skillRef: string }) => Promise<void>;
|
||||
updateSkill: (input: { content: string; path: string; skillRef: string }) => Promise<void>;
|
||||
writeSkillFile: (input: { content: string; path: string; skillRef: string }) => Promise<void>;
|
||||
export interface SkillCreateAuthoringInput {
|
||||
candidateSkills?: SkillManagementCandidateSkill[];
|
||||
decision: SkillManagementDecision;
|
||||
signal: SkillManagementActionInput;
|
||||
sourceAgentDocumentId?: string;
|
||||
sourceDocumentContent?: string;
|
||||
}
|
||||
|
||||
export interface SkillCreateAuthoringResult {
|
||||
bodyMarkdown: string;
|
||||
confidence?: number;
|
||||
description: string;
|
||||
name: string;
|
||||
reason?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SkillManagementOperationService {
|
||||
createSkill: (input: CreateSkillInput) => Promise<SkillDetail>;
|
||||
getSkill: (input: {
|
||||
agentDocumentId?: string;
|
||||
agentId: string;
|
||||
includeContent?: boolean;
|
||||
name?: string;
|
||||
}) => Promise<SkillDetail | undefined>;
|
||||
listSkills: (input: { agentId: string }) => Promise<SkillSummary[]>;
|
||||
renameSkill: (input: RenameSkillInput) => Promise<SkillDetail | undefined>;
|
||||
replaceSkillIndex: (input: ReplaceSkillIndexInput) => Promise<SkillDetail | undefined>;
|
||||
}
|
||||
|
||||
const SkillManagementDecisionSchema = z.object({
|
||||
@@ -215,19 +257,30 @@ const SkillManagementDecisionSchema = z.object({
|
||||
documentRefs: z.array(z.string()).default([]),
|
||||
reason: z.string().nullable(),
|
||||
requiredReads: z.array(z.string()),
|
||||
targetSkillIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const SkillMaintainerOperationSchema = z.object({
|
||||
arguments: z.record(z.string(), z.unknown()),
|
||||
name: z.enum(['updateSkill', 'writeSkillFile', 'removeSkillFile']),
|
||||
targetSkillRefs: z.array(z.string()),
|
||||
});
|
||||
|
||||
const SkillMaintainerWorkflowResultSchema = z.object({
|
||||
bodyMarkdown: z.string(),
|
||||
confidence: z.number().min(0).max(1).nullable().default(null),
|
||||
operations: z.array(SkillMaintainerOperationSchema),
|
||||
proposedLifecycleActions: z.array(z.record(z.string(), z.unknown())).default([]),
|
||||
description: z.string().nullable().default(null),
|
||||
reason: z.string().nullable().default(null),
|
||||
rename: z
|
||||
.object({
|
||||
newName: z.string().nullable().default(null),
|
||||
newTitle: z.string().nullable().default(null),
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
});
|
||||
|
||||
const SkillCreateAuthoringResultSchema = z.object({
|
||||
bodyMarkdown: z.string(),
|
||||
confidence: z.number().min(0).max(1).nullable().default(null),
|
||||
description: z.string(),
|
||||
name: z.string(),
|
||||
reason: z.string().nullable().default(null),
|
||||
title: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
const SkillManagementDecisionGenerateObjectSchema = {
|
||||
@@ -242,9 +295,16 @@ const SkillManagementDecisionGenerateObjectSchema = {
|
||||
documentRefs: { items: { type: 'string' }, type: 'array' },
|
||||
reason: { type: ['string', 'null'] },
|
||||
requiredReads: { items: { type: 'string' }, type: 'array' },
|
||||
targetSkillIds: { items: { type: 'string' }, type: 'array' },
|
||||
targetSkillRefs: { items: { type: 'string' }, type: 'array' },
|
||||
},
|
||||
required: ['action', 'confidence', 'documentRefs', 'reason', 'requiredReads', 'targetSkillIds'],
|
||||
required: [
|
||||
'action',
|
||||
'confidence',
|
||||
'documentRefs',
|
||||
'reason',
|
||||
'requiredReads',
|
||||
'targetSkillRefs',
|
||||
],
|
||||
type: 'object',
|
||||
},
|
||||
strict: true,
|
||||
@@ -254,53 +314,53 @@ const SkillMaintainerWorkflowResultBaseGenerateObjectSchema = {
|
||||
schema: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
bodyMarkdown: { type: 'string' },
|
||||
confidence: {
|
||||
anyOf: [{ maximum: 1, minimum: 0, type: 'number' }, { type: 'null' }],
|
||||
},
|
||||
operations: {
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
arguments: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
content: { type: ['string', 'null'] },
|
||||
path: { type: 'string' },
|
||||
skillRef: { type: 'string' },
|
||||
},
|
||||
required: ['skillRef', 'path', 'content'],
|
||||
type: 'object',
|
||||
},
|
||||
name: {
|
||||
enum: ['updateSkill', 'writeSkillFile', 'removeSkillFile'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['arguments', 'name'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
proposedLifecycleActions: {
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
reason: { type: ['string', 'null'] },
|
||||
type: { type: 'string' },
|
||||
},
|
||||
required: ['type', 'reason'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
description: { type: ['string', 'null'] },
|
||||
reason: { type: ['string', 'null'] },
|
||||
rename: {
|
||||
anyOf: [
|
||||
{
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
newName: { type: ['string', 'null'] },
|
||||
newTitle: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['newName', 'newTitle'],
|
||||
type: 'object',
|
||||
},
|
||||
{ type: 'null' },
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['confidence', 'operations', 'proposedLifecycleActions', 'reason'],
|
||||
required: ['bodyMarkdown', 'confidence', 'description', 'reason', 'rename'],
|
||||
type: 'object',
|
||||
},
|
||||
strict: true,
|
||||
} satisfies Omit<GenerateObjectSchema, 'name'>;
|
||||
|
||||
const SkillCreateAuthoringResultGenerateObjectSchema = {
|
||||
name: 'agent_signal_skill_create',
|
||||
schema: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
bodyMarkdown: { type: 'string' },
|
||||
confidence: {
|
||||
anyOf: [{ maximum: 1, minimum: 0, type: 'number' }, { type: 'null' }],
|
||||
},
|
||||
description: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
reason: { type: ['string', 'null'] },
|
||||
title: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['name', 'title', 'description', 'bodyMarkdown', 'reason', 'confidence'],
|
||||
type: 'object',
|
||||
},
|
||||
strict: true,
|
||||
} satisfies GenerateObjectSchema;
|
||||
|
||||
const isSkillManagementDecisionAction = (value: unknown): value is SkillManagementDecisionAction =>
|
||||
value === 'create' ||
|
||||
value === 'refine' ||
|
||||
@@ -327,7 +387,7 @@ const normalizeSkillManagementDecision = (decision: unknown): SkillManagementDec
|
||||
const documentRefs = getStringArray(record.documentRefs);
|
||||
const reason = typeof record.reason === 'string' ? record.reason : undefined;
|
||||
const requiredReads = getStringArray(record.requiredReads);
|
||||
const targetSkillIds = getStringArray(record.targetSkillIds);
|
||||
const targetSkillRefs = getStringArray(record.targetSkillRefs);
|
||||
|
||||
return {
|
||||
action,
|
||||
@@ -335,7 +395,7 @@ const normalizeSkillManagementDecision = (decision: unknown): SkillManagementDec
|
||||
...(documentRefs === undefined ? {} : { documentRefs }),
|
||||
...(reason === undefined ? {} : { reason }),
|
||||
...(requiredReads === undefined ? {} : { requiredReads }),
|
||||
...(targetSkillIds === undefined ? {} : { targetSkillIds }),
|
||||
...(targetSkillRefs === undefined ? {} : { targetSkillRefs }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -372,14 +432,15 @@ const skillDecisionManifest = {
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Read one agent document by document id for attribution before deciding.',
|
||||
description: 'Read one agent document by agent document id for attribution before deciding.',
|
||||
name: 'readDocument',
|
||||
parameters: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agentDocumentId: { type: 'string' },
|
||||
documentId: { type: 'string' },
|
||||
},
|
||||
required: ['documentId'],
|
||||
required: ['agentDocumentId'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
@@ -444,10 +505,10 @@ const executeSkillDecisionRuntimeTool = async (
|
||||
}
|
||||
|
||||
if (toolCall.apiName === 'readDocument') {
|
||||
const documentId = toNullableString(args.documentId);
|
||||
if (!documentId) return { error: 'documentId is required' };
|
||||
const agentDocumentId = toNullableString(args.agentDocumentId);
|
||||
if (!agentDocumentId) return { error: 'agentDocumentId is required' };
|
||||
|
||||
return input.tools.readDocument({ documentId });
|
||||
return input.tools.readDocument({ agentDocumentId });
|
||||
}
|
||||
|
||||
return { error: `Unsupported skill decision tool: ${toolCall.apiName}` };
|
||||
@@ -769,7 +830,7 @@ const toSkillManagementDecision = (
|
||||
...(value.documentRefs.length === 0 ? {} : { documentRefs: value.documentRefs }),
|
||||
...(value.reason === null ? {} : { reason: value.reason }),
|
||||
...(value.requiredReads.length === 0 ? {} : { requiredReads: value.requiredReads }),
|
||||
...(value.targetSkillIds.length === 0 ? {} : { targetSkillIds: value.targetSkillIds }),
|
||||
...(value.targetSkillRefs.length === 0 ? {} : { targetSkillRefs: value.targetSkillRefs }),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -784,7 +845,7 @@ const toSkillManagementDecision = (
|
||||
* - Managed skill folders use their directory filename as the package id
|
||||
*
|
||||
* Returns:
|
||||
* - Agent-scoped candidate ids that are package names for `targetSkillIds`
|
||||
* - Agent-scoped candidate ids that are managed skill bundle agent document ids
|
||||
*/
|
||||
export const collectAgentSkillDecisionCandidates = (
|
||||
documents: AgentDocument[],
|
||||
@@ -792,14 +853,14 @@ export const collectAgentSkillDecisionCandidates = (
|
||||
const candidates: SkillManagementCandidateSkill[] = [];
|
||||
|
||||
for (const document of documents) {
|
||||
const folder = getSkillFolder(documents, 'agent', document.filename);
|
||||
const folder = getSkillBundle(documents, 'agent', document.filename);
|
||||
|
||||
if (!folder || folder.id !== document.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
id: document.filename,
|
||||
id: document.id,
|
||||
name: document.title ?? document.filename,
|
||||
scope: 'agent',
|
||||
});
|
||||
@@ -823,6 +884,7 @@ const createDefaultSkillDecisionToolset = (
|
||||
: await agentDocumentModel.findByAgent(agentId);
|
||||
|
||||
return documents.map((document) => ({
|
||||
agentDocumentId: document.id,
|
||||
documentId: document.documentId,
|
||||
filename: document.filename,
|
||||
title: document.title,
|
||||
@@ -837,22 +899,21 @@ const createDefaultSkillDecisionToolset = (
|
||||
.filter((receipt) => receipt.domainKey.startsWith('document:'))
|
||||
.filter((receipt) => !messageId || receipt.messageId === messageId)
|
||||
.flatMap((receipt) =>
|
||||
(receipt.relatedObjects ?? [])
|
||||
.filter((object) => object.objectType === 'document')
|
||||
.map((object) => ({
|
||||
documentId: object.objectId,
|
||||
relation: object.relation,
|
||||
summary: receipt.summary,
|
||||
})),
|
||||
(receipt.relatedObjects ?? []).filter(isAgentDocumentRelatedObject).map((object) => ({
|
||||
agentDocumentId: object.objectId,
|
||||
relation: object.relation,
|
||||
summary: receipt.summary,
|
||||
})),
|
||||
);
|
||||
},
|
||||
readDocument: async ({ documentId }) => {
|
||||
const snapshot = await agentDocumentsService.getDocumentSnapshotById(documentId);
|
||||
if (!snapshot) return { documentId };
|
||||
readDocument: async ({ agentDocumentId }) => {
|
||||
const snapshot = await agentDocumentsService.getDocumentSnapshotById(agentDocumentId);
|
||||
if (!snapshot) return { agentDocumentId };
|
||||
|
||||
return {
|
||||
agentDocumentId,
|
||||
content: snapshot.content,
|
||||
documentId,
|
||||
documentId: snapshot.documentId,
|
||||
filename: snapshot.filename,
|
||||
title: snapshot.title,
|
||||
};
|
||||
@@ -863,12 +924,29 @@ const createDefaultSkillDecisionToolset = (
|
||||
const toSkillMaintainerWorkflowResult = (
|
||||
value: z.infer<typeof SkillMaintainerWorkflowResultSchema>,
|
||||
): SkillMaintainerWorkflowResult => ({
|
||||
operations: value.operations,
|
||||
bodyMarkdown: value.bodyMarkdown,
|
||||
...(value.confidence === null ? {} : { confidence: value.confidence }),
|
||||
...(value.proposedLifecycleActions.length === 0
|
||||
? {}
|
||||
: { proposedLifecycleActions: value.proposedLifecycleActions }),
|
||||
...(value.description === null ? {} : { description: value.description }),
|
||||
...(value.reason === null ? {} : { reason: value.reason }),
|
||||
...(value.rename === null
|
||||
? {}
|
||||
: {
|
||||
rename: {
|
||||
...(value.rename.newName === null ? {} : { newName: value.rename.newName }),
|
||||
...(value.rename.newTitle === null ? {} : { newTitle: value.rename.newTitle }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const toSkillCreateAuthoringResult = (
|
||||
value: z.infer<typeof SkillCreateAuthoringResultSchema>,
|
||||
): SkillCreateAuthoringResult => ({
|
||||
bodyMarkdown: value.bodyMarkdown,
|
||||
description: value.description,
|
||||
name: value.name,
|
||||
...(value.confidence === null ? {} : { confidence: value.confidence }),
|
||||
...(value.reason === null ? {} : { reason: value.reason }),
|
||||
...(value.title === null ? {} : { title: value.title }),
|
||||
});
|
||||
|
||||
class SkillManagementDecisionAgentService {
|
||||
@@ -913,7 +991,7 @@ class SkillManagementDecisionAgentService {
|
||||
documentRefs: result.documentRefs ?? [],
|
||||
reason: result.reason ?? null,
|
||||
requiredReads: result.requiredReads ?? [],
|
||||
targetSkillIds: result.targetSkillIds ?? [],
|
||||
targetSkillRefs: result.targetSkillRefs ?? [],
|
||||
action: result.action,
|
||||
}),
|
||||
);
|
||||
@@ -982,6 +1060,57 @@ class SkillMaintainerWorkflowAgentService {
|
||||
}
|
||||
}
|
||||
|
||||
class SkillCreateAuthoringAgentService {
|
||||
private readonly modelConfig: SkillManagementAgentModelConfig;
|
||||
|
||||
constructor(
|
||||
private db: LobeChatDatabase,
|
||||
private userId: string,
|
||||
modelConfig: Partial<SkillManagementAgentModelConfig> = {},
|
||||
) {
|
||||
this.modelConfig = {
|
||||
model: modelConfig.model ?? DEFAULT_MINI_SYSTEM_AGENT_ITEM.model,
|
||||
provider: modelConfig.provider ?? DEFAULT_MINI_SYSTEM_AGENT_ITEM.provider,
|
||||
};
|
||||
}
|
||||
|
||||
async run(input: SkillCreateAuthoringInput): Promise<SkillCreateAuthoringResult> {
|
||||
const modelRuntime = await initModelRuntimeFromDB(
|
||||
this.db,
|
||||
this.userId,
|
||||
this.modelConfig.provider,
|
||||
);
|
||||
|
||||
const result = await modelRuntime.generateObject(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
content: AGENT_SKILL_CREATE_SYSTEM_ROLE,
|
||||
role: 'system',
|
||||
},
|
||||
{
|
||||
content: createAgentSkillCreatePrompt({
|
||||
agentId: input.signal.agentId ?? '',
|
||||
...(input.candidateSkills?.length ? { candidateSkills: input.candidateSkills } : {}),
|
||||
evidence: input.signal.evidence ?? [],
|
||||
feedbackMessage: input.signal.message,
|
||||
sourceAgentDocumentId: input.sourceAgentDocumentId,
|
||||
sourceDocumentContent: input.sourceDocumentContent,
|
||||
turnContext: input.signal.serializedContext,
|
||||
}),
|
||||
role: 'user',
|
||||
},
|
||||
] as never[],
|
||||
model: this.modelConfig.model,
|
||||
schema: SkillCreateAuthoringResultGenerateObjectSchema,
|
||||
},
|
||||
{ metadata: { trigger: RequestTrigger.Memory } },
|
||||
);
|
||||
|
||||
return toSkillCreateAuthoringResult(SkillCreateAuthoringResultSchema.parse(result));
|
||||
}
|
||||
}
|
||||
|
||||
const finalizeAttempt = (
|
||||
startedAt: number,
|
||||
status: SignalAttempt['status'],
|
||||
@@ -1032,27 +1161,6 @@ const isSkillManagementAction = (action: BaseAction): action is ActionSkillManag
|
||||
return action.actionType === AGENT_SIGNAL_POLICY_ACTION_TYPES.skillManagementHandle;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes feedback text into a skill package name.
|
||||
*
|
||||
* Before:
|
||||
* - "This review workflow should become a reusable checklist."
|
||||
*
|
||||
* After:
|
||||
* - "review-workflow-reusable-checklist"
|
||||
*/
|
||||
export const normalizeSkillPackageName = (message: string) => {
|
||||
const words = message
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 2 && !['should', 'this', 'that', 'become'].includes(word))
|
||||
.slice(0, 5);
|
||||
|
||||
return words.length > 0 ? words.join('-') : 'agent-signal-skill';
|
||||
};
|
||||
|
||||
export const createSkillDecisionRunner = (options: SkillManagementActionHandlerOptions) => {
|
||||
const agent = new SkillManagementDecisionAgentService(
|
||||
options.db,
|
||||
@@ -1080,154 +1188,30 @@ const resolveSkillDecisionCandidates = async (
|
||||
);
|
||||
};
|
||||
|
||||
const toSkillTitle = (skillName: string) =>
|
||||
skillName
|
||||
.split('-')
|
||||
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
|
||||
.join(' ');
|
||||
|
||||
const toSkillContent = (input: SkillManagementActionInput, skillName: string) => {
|
||||
const evidence = input.evidence?.map((item) => `- ${item.cue}: ${item.excerpt}`).join('\n');
|
||||
const context = input.serializedContext ? `\n\n## Context\n${input.serializedContext}` : '';
|
||||
const evidenceBlock = evidence ? `\n\n## Evidence\n${evidence}` : '';
|
||||
const reason = input.reason ? `\n\n## Reason\n${input.reason}` : '';
|
||||
|
||||
return `# ${toSkillTitle(skillName)}
|
||||
|
||||
## Trigger
|
||||
Use when similar feedback or work requires this reusable procedure.
|
||||
|
||||
## Procedure
|
||||
- Apply the workflow requested in the feedback.
|
||||
- Preserve concrete checks, ordering, and verification criteria from the source turn.
|
||||
- Re-check the result before responding.
|
||||
|
||||
## Source Feedback
|
||||
${input.message}${reason}${evidenceBlock}${context}
|
||||
`;
|
||||
};
|
||||
|
||||
const createDefaultSkillMaintainerService = (
|
||||
const createDefaultSkillManagementService = (
|
||||
options: SkillManagementActionHandlerOptions,
|
||||
agentId: string,
|
||||
): SkillMaintainerFileOperationService => {
|
||||
const vfs = new AgentDocumentVfsService(options.db, options.userId);
|
||||
const agentDocumentModel = new AgentDocumentModel(options.db, options.userId);
|
||||
const ctx = { agentId };
|
||||
): SkillManagementOperationService =>
|
||||
new SkillManagementDocumentService(options.db, options.userId);
|
||||
|
||||
return new SkillMaintainerService({
|
||||
adapter: new VfsSkillPackageAdapter({
|
||||
delete: async (path) => {
|
||||
await vfs.delete(path, ctx);
|
||||
},
|
||||
list: (path) => vfs.list(path, ctx),
|
||||
read: async (path) => {
|
||||
return (await vfs.read(path, ctx)).content;
|
||||
},
|
||||
write: async (path, content) => {
|
||||
await vfs.write(path, content, ctx, { createMode: 'if-missing' });
|
||||
},
|
||||
}),
|
||||
resolver: new SkillReferenceResolver({
|
||||
findAgentSkillById: async (id) => {
|
||||
const skillFolder = getSkillFolder(
|
||||
await agentDocumentModel.findByAgent(agentId),
|
||||
'agent',
|
||||
id,
|
||||
);
|
||||
|
||||
return skillFolder ? { id } : undefined;
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const getSkillTargets = (decision: SkillManagementDecision) => decision.targetSkillIds ?? [];
|
||||
const getSkillTargets = (decision: SkillManagementDecision) => decision.targetSkillRefs ?? [];
|
||||
|
||||
const isMaintainerDecision = (
|
||||
decision: SkillManagementDecision,
|
||||
): decision is SkillManagementDecision & { action: 'consolidate' | 'refine' } =>
|
||||
decision.action === 'refine' || decision.action === 'consolidate';
|
||||
|
||||
// TODO(@nekomeowww): Split the maintainer workflow orchestration out of this action file.
|
||||
// This module currently owns decision schemas, LLM runners, VFS-backed service construction,
|
||||
// file-operation application, and create/refine/consolidate action orchestration. Keeping all
|
||||
// of that here makes the action handler harder to scan and will make future maintainer rules
|
||||
// risky to add because model contracts and file mutation behavior change in the same module.
|
||||
// Expected shape: keep this file focused on action input/output, idempotency, and top-level
|
||||
// dispatch; move refine/consolidate runner setup, target reads, policy checks, and operation
|
||||
// application into a small `skillMaintainerWorkflow` module with focused tests.
|
||||
const readTargetSkills = async (
|
||||
service: SkillMaintainerFileOperationService,
|
||||
service: SkillManagementOperationService,
|
||||
agentId: string,
|
||||
skillRefs: string[],
|
||||
) => {
|
||||
return Promise.all(
|
||||
skillRefs.map(async (skillRef) => ({
|
||||
content: await service.readSkillFile({ path: 'SKILL.md', skillRef }),
|
||||
id: skillRef,
|
||||
metadata: {},
|
||||
})),
|
||||
const results = await Promise.all(
|
||||
skillRefs.map((agentDocumentId) =>
|
||||
service.getSkill({ agentDocumentId, agentId, includeContent: true }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const getOperationStringArgument = (
|
||||
operation: SkillMaintainerOperation,
|
||||
key: 'content' | 'path' | 'skillRef',
|
||||
fallback?: string,
|
||||
) => {
|
||||
const value = operation.arguments[key];
|
||||
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
|
||||
};
|
||||
|
||||
const applyMaintainerOperations = async (
|
||||
service: SkillMaintainerFileOperationService,
|
||||
operations: SkillMaintainerOperation[],
|
||||
targetSkillIds: string[],
|
||||
) => {
|
||||
const allowedSkillRefs = new Set(targetSkillIds);
|
||||
const defaultSkillRef = targetSkillIds[0];
|
||||
|
||||
if (!defaultSkillRef) {
|
||||
throw new Error('Invalid maintainer workflow: missing target skill refs');
|
||||
}
|
||||
|
||||
const normalizedOperations = operations.map((operation) => {
|
||||
const skillRef = getOperationStringArgument(operation, 'skillRef', defaultSkillRef);
|
||||
const path = getOperationStringArgument(operation, 'path');
|
||||
|
||||
if (!skillRef || !path) {
|
||||
throw new Error(`Invalid ${operation.name} operation: missing skillRef or path`);
|
||||
}
|
||||
|
||||
if (!allowedSkillRefs.has(skillRef)) {
|
||||
throw new Error(
|
||||
`Invalid ${operation.name} operation: skillRef is not a decision target: ${skillRef}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { operation, path, skillRef };
|
||||
});
|
||||
|
||||
for (const { operation, path, skillRef } of normalizedOperations) {
|
||||
if (operation.name === 'removeSkillFile') {
|
||||
await service.removeSkillFile({ path, skillRef });
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = getOperationStringArgument(operation, 'content');
|
||||
|
||||
if (!content) {
|
||||
throw new Error(`Invalid ${operation.name} operation: missing content`);
|
||||
}
|
||||
|
||||
if (operation.name === 'updateSkill') {
|
||||
await service.updateSkill({ content, path, skillRef });
|
||||
continue;
|
||||
}
|
||||
|
||||
await service.writeSkillFile({ content, path, skillRef });
|
||||
}
|
||||
return results.filter((skill): skill is SkillDetail => Boolean(skill));
|
||||
};
|
||||
|
||||
const runMaintainerWorkflow = async (
|
||||
@@ -1243,20 +1227,20 @@ const runMaintainerWorkflow = async (
|
||||
};
|
||||
}
|
||||
|
||||
const targetSkillIds = getSkillTargets(decision);
|
||||
const targetSkillRefs = getSkillTargets(decision);
|
||||
const minimumTargets = decision.action === 'consolidate' ? 2 : 1;
|
||||
|
||||
if (targetSkillIds.length < minimumTargets) {
|
||||
if (targetSkillRefs.length < minimumTargets) {
|
||||
return {
|
||||
decision,
|
||||
detail: `Skill-management ${decision.action} requires targetSkillIds from the decision agent.`,
|
||||
detail: `Skill-management ${decision.action} requires targetSkillRefs from the decision agent.`,
|
||||
status: 'skipped',
|
||||
};
|
||||
}
|
||||
|
||||
const service =
|
||||
options.skillMaintainerServiceFactory?.({ agentId: input.agentId }) ??
|
||||
createDefaultSkillMaintainerService(options, input.agentId);
|
||||
options.skillManagementServiceFactory?.({ agentId: input.agentId }) ??
|
||||
createDefaultSkillManagementService(options);
|
||||
const workflowRunner =
|
||||
options.skillMaintainerRunner ??
|
||||
((workflowInput: SkillMaintainerWorkflowInput) =>
|
||||
@@ -1265,20 +1249,51 @@ const runMaintainerWorkflow = async (
|
||||
options.userId,
|
||||
options.skillDecisionModel,
|
||||
).run(workflowInput));
|
||||
const targetSkills = await readTargetSkills(service, targetSkillIds);
|
||||
const targetSkills = await readTargetSkills(service, input.agentId, targetSkillRefs);
|
||||
|
||||
if (targetSkills.length < minimumTargets) {
|
||||
return {
|
||||
decision,
|
||||
detail: `Skill-management ${decision.action} could not resolve targetSkillRefs.`,
|
||||
status: 'skipped',
|
||||
};
|
||||
}
|
||||
|
||||
const workflowResult = toSkillMaintainerWorkflowResult(
|
||||
SkillMaintainerWorkflowResultSchema.parse(
|
||||
await workflowRunner({
|
||||
decision,
|
||||
signal: input,
|
||||
targetSkills,
|
||||
targetSkills: targetSkills.map((skill) => ({
|
||||
content: skill.content ?? '',
|
||||
id: skill.bundle.agentDocumentId,
|
||||
metadata: { frontmatter: skill.frontmatter },
|
||||
name: skill.name,
|
||||
})),
|
||||
type: decision.action,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const canonical = targetSkills[0];
|
||||
|
||||
try {
|
||||
await applyMaintainerOperations(service, workflowResult.operations, targetSkillIds);
|
||||
if (workflowResult.rename?.newName || workflowResult.rename?.newTitle) {
|
||||
await service.renameSkill({
|
||||
agentDocumentId: canonical.bundle.agentDocumentId,
|
||||
agentId: input.agentId,
|
||||
newName: workflowResult.rename.newName,
|
||||
newTitle: workflowResult.rename.newTitle,
|
||||
updateReason: workflowResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
await service.replaceSkillIndex({
|
||||
agentDocumentId: canonical.bundle.agentDocumentId,
|
||||
agentId: input.agentId,
|
||||
bodyMarkdown: workflowResult.bodyMarkdown,
|
||||
description: workflowResult.description,
|
||||
updateReason: workflowResult.reason,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
decision,
|
||||
@@ -1294,6 +1309,88 @@ const runMaintainerWorkflow = async (
|
||||
};
|
||||
};
|
||||
|
||||
const readCreateSourceDocument = async (
|
||||
input: SkillManagementActionInput,
|
||||
options: SkillManagementActionHandlerOptions,
|
||||
decision: SkillManagementDecision,
|
||||
) => {
|
||||
const sourceAgentDocumentId = decision.documentRefs?.[0];
|
||||
|
||||
if (!input.agentId || !sourceAgentDocumentId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const snapshot = await new AgentDocumentsService(
|
||||
options.db,
|
||||
options.userId,
|
||||
).getDocumentSnapshotById(sourceAgentDocumentId);
|
||||
|
||||
return {
|
||||
sourceAgentDocumentId,
|
||||
sourceDocumentContent: snapshot?.content,
|
||||
};
|
||||
};
|
||||
|
||||
const runCreateWorkflow = async (
|
||||
input: SkillManagementActionInput,
|
||||
options: SkillManagementActionHandlerOptions,
|
||||
decision: SkillManagementDecision,
|
||||
): Promise<SkillManagementActionResult> => {
|
||||
if (!input.agentId) {
|
||||
return {
|
||||
decision,
|
||||
detail: 'Missing agentId for skill-management create workflow.',
|
||||
status: 'skipped',
|
||||
};
|
||||
}
|
||||
|
||||
const source = await readCreateSourceDocument(input, options, decision);
|
||||
const createRunner =
|
||||
options.skillCreateRunner ??
|
||||
((authoringInput: SkillCreateAuthoringInput) =>
|
||||
new SkillCreateAuthoringAgentService(
|
||||
options.db,
|
||||
options.userId,
|
||||
options.skillDecisionModel,
|
||||
).run(authoringInput));
|
||||
const authored = toSkillCreateAuthoringResult(
|
||||
SkillCreateAuthoringResultSchema.parse(
|
||||
await createRunner({
|
||||
candidateSkills: input.candidateSkills,
|
||||
decision,
|
||||
signal: input,
|
||||
...source,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const service =
|
||||
options.skillManagementServiceFactory?.({ agentId: input.agentId }) ??
|
||||
createDefaultSkillManagementService(options);
|
||||
|
||||
try {
|
||||
await service.createSkill({
|
||||
agentId: input.agentId,
|
||||
bodyMarkdown: authored.bodyMarkdown,
|
||||
description: authored.description,
|
||||
name: authored.name,
|
||||
sourceAgentDocumentId: source.sourceAgentDocumentId,
|
||||
title: authored.title ?? authored.name,
|
||||
});
|
||||
|
||||
return {
|
||||
decision,
|
||||
detail: authored.reason ?? `Created skill ${authored.name}.`,
|
||||
status: 'applied',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
return { decision, detail: error.message, status: 'skipped' };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const runSkillManagementAction = async (
|
||||
input: SkillManagementActionInput,
|
||||
options: SkillManagementActionHandlerOptions,
|
||||
@@ -1335,27 +1432,7 @@ export const runSkillManagementAction = async (
|
||||
return runMaintainerWorkflow(input, options, decision);
|
||||
}
|
||||
|
||||
const skillName = normalizeSkillPackageName(input.message);
|
||||
const snapshot = await createMarkdownEditorSnapshot(toSkillContent(input, skillName));
|
||||
|
||||
try {
|
||||
await createSkillTree({
|
||||
agentDocumentModel: new AgentDocumentModel(options.db, options.userId),
|
||||
agentId: input.agentId,
|
||||
content: snapshot.content,
|
||||
editorData: snapshot.editorData,
|
||||
namespace: 'agent',
|
||||
skillName,
|
||||
});
|
||||
|
||||
return { decision, detail: `Created skill ${skillName}.`, status: 'applied' };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
return { decision, detail: error.message, status: 'skipped' };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
return runCreateWorkflow(input, options, decision);
|
||||
};
|
||||
|
||||
export const handleSkillManagementAction = async (
|
||||
@@ -1495,7 +1572,7 @@ export const handleSkillManagementAction = async (
|
||||
*
|
||||
* Downstream:
|
||||
* - {@link runSkillManagementAction}
|
||||
* - {@link createSkillTree}
|
||||
* - {@link SkillManagementDocumentService}
|
||||
*/
|
||||
export const defineSkillManagementActionHandler = (
|
||||
options: SkillManagementActionHandlerOptions,
|
||||
|
||||
@@ -16,7 +16,7 @@ interface FeedbackDomainJudgeResolverInput {
|
||||
chain: SignalFeedbackSatisfaction['chain'];
|
||||
feedback: Pick<
|
||||
SignalFeedbackSatisfaction['payload'],
|
||||
'confidence' | 'evidence' | 'message' | 'messageId' | 'reason' | 'result'
|
||||
'confidence' | 'evidence' | 'message' | 'messageId' | 'reason' | 'result' | 'serializedContext'
|
||||
>;
|
||||
source: SignalFeedbackSatisfaction['source'];
|
||||
sourceHints: SignalFeedbackSatisfaction['payload']['sourceHints'];
|
||||
@@ -64,6 +64,7 @@ const createDomainResolver = (
|
||||
message: signal.feedback.message,
|
||||
reason: signal.feedback.reason,
|
||||
result: signal.feedback.result,
|
||||
serializedContext: signal.feedback.serializedContext,
|
||||
})
|
||||
).targets;
|
||||
};
|
||||
@@ -108,6 +109,7 @@ export const createFeedbackDomainJudgeSignalHandler = (
|
||||
messageId: input.payload.messageId,
|
||||
reason: input.payload.reason,
|
||||
result: input.payload.result,
|
||||
serializedContext: input.payload.serializedContext,
|
||||
},
|
||||
source: input.source,
|
||||
sourceHints: input.payload.sourceHints,
|
||||
|
||||
@@ -131,6 +131,12 @@ export interface JudgeFeedbackDomainsParams {
|
||||
message: string;
|
||||
reason: string;
|
||||
result: AgentSignalFeedbackSatisfactionResult;
|
||||
/**
|
||||
* Recent thread context assembled by the workflow.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
serializedContext?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
|
||||
import type { AgentDocumentSourceType } from '@/database/models/agentDocuments/types';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { TopicDocumentModel } from '@/database/models/topicDocument';
|
||||
import { DocumentService } from '@/server/services/document';
|
||||
@@ -29,7 +30,7 @@ const toServiceResult = (doc: {
|
||||
fileType: string;
|
||||
id: string;
|
||||
source: string;
|
||||
sourceType: 'api' | 'file' | 'web' | 'topic';
|
||||
sourceType: AgentDocumentSourceType;
|
||||
title: string | null;
|
||||
totalCharCount: number;
|
||||
updatedAt: Date;
|
||||
@@ -40,7 +41,7 @@ const toServiceResult = (doc: {
|
||||
fileType: doc.fileType,
|
||||
id: doc.id,
|
||||
source: doc.source,
|
||||
sourceType: doc.sourceType === 'topic' ? 'api' : doc.sourceType,
|
||||
sourceType: doc.sourceType === 'file' || doc.sourceType === 'web' ? doc.sourceType : 'api',
|
||||
title: doc.title,
|
||||
totalCharCount: doc.totalCharCount,
|
||||
updatedAt: doc.updatedAt,
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
// @vitest-environment node
|
||||
import type { LobeChatDatabase, Transaction } from '@lobechat/database';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
DocumentLoadFormat,
|
||||
DocumentLoadPosition,
|
||||
DocumentLoadRule,
|
||||
PolicyLoad,
|
||||
} from '@/database/models/agentDocuments';
|
||||
|
||||
import type { AgentDocumentEditorSnapshot } from '../agentDocuments/headlessEditor';
|
||||
import {
|
||||
AGENT_SKILL_TEMPLATE_ID,
|
||||
SKILL_BUNDLE_FILE_TYPE,
|
||||
SKILL_INDEX_FILE_TYPE,
|
||||
SKILL_INDEX_FILENAME,
|
||||
SKILL_MANAGEMENT_SOURCE,
|
||||
SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
} from './constants';
|
||||
import type { SkillManagementAgentDocumentModel } from './SkillManagementDocumentService';
|
||||
import { SkillManagementDocumentService } from './SkillManagementDocumentService';
|
||||
import type { SkillAgentDocument } from './types';
|
||||
|
||||
const now = new Date('2026-05-02T00:00:00.000Z');
|
||||
|
||||
const createSnapshot = async (content: string): Promise<AgentDocumentEditorSnapshot> => ({
|
||||
content,
|
||||
editorData: { markdown: content },
|
||||
});
|
||||
|
||||
class InMemoryAgentDocumentModel implements SkillManagementAgentDocumentModel {
|
||||
documents: SkillAgentDocument[] = [];
|
||||
createCalls: Array<{
|
||||
agentId: string;
|
||||
content: string;
|
||||
filename: string;
|
||||
params?: Record<string, unknown>;
|
||||
}> = [];
|
||||
identityUpdateCalls: Array<{ agentDocumentId: string; params: Record<string, unknown> }> = [];
|
||||
updateCalls: Array<{ agentDocumentId: string; params?: Record<string, unknown> }> = [];
|
||||
|
||||
private nextId = 1;
|
||||
|
||||
async convertAgentDocumentToSkillIndex(params: {
|
||||
agentDocumentId: string;
|
||||
content: string;
|
||||
editorData?: Record<string, unknown>;
|
||||
filename: string;
|
||||
metadata: Record<string, unknown>;
|
||||
parentId: string;
|
||||
source: string;
|
||||
sourceType: typeof SKILL_MANAGEMENT_SOURCE_TYPE;
|
||||
title: string;
|
||||
}): Promise<SkillAgentDocument | undefined> {
|
||||
return this.convertAgentDocumentToSkillIndexWithTx({} as Transaction, params);
|
||||
}
|
||||
|
||||
async convertAgentDocumentToSkillIndexWithTx(
|
||||
_trx: Transaction,
|
||||
params: {
|
||||
agentDocumentId: string;
|
||||
content: string;
|
||||
editorData?: Record<string, unknown>;
|
||||
filename: string;
|
||||
metadata: Record<string, unknown>;
|
||||
parentId: string;
|
||||
source: string;
|
||||
sourceType: typeof SKILL_MANAGEMENT_SOURCE_TYPE;
|
||||
title: string;
|
||||
},
|
||||
): Promise<SkillAgentDocument | undefined> {
|
||||
const existing = this.documents.find((doc) => doc.id === params.agentDocumentId);
|
||||
if (!existing) return undefined;
|
||||
|
||||
Object.assign(existing, {
|
||||
content: params.content,
|
||||
...(params.editorData !== undefined && { editorData: params.editorData }),
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
filename: params.filename,
|
||||
metadata: params.metadata,
|
||||
parentId: params.parentId,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: params.source,
|
||||
sourceType: params.sourceType,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: params.title,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
async create(
|
||||
agentId: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
params?: {
|
||||
editorData?: Record<string, unknown>;
|
||||
fileType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
parentId?: string | null;
|
||||
policyLoad?: PolicyLoad;
|
||||
source?: string;
|
||||
sourceType?: typeof SKILL_MANAGEMENT_SOURCE_TYPE;
|
||||
templateId?: string;
|
||||
title?: string;
|
||||
},
|
||||
): Promise<SkillAgentDocument> {
|
||||
return this.createWithTx({} as Transaction, agentId, filename, content, params);
|
||||
}
|
||||
|
||||
async createWithTx(
|
||||
_trx: Transaction,
|
||||
agentId: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
params?: {
|
||||
editorData?: Record<string, unknown>;
|
||||
fileType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
parentId?: string | null;
|
||||
policyLoad?: PolicyLoad;
|
||||
source?: string;
|
||||
sourceType?: typeof SKILL_MANAGEMENT_SOURCE_TYPE;
|
||||
templateId?: string;
|
||||
title?: string;
|
||||
},
|
||||
): Promise<SkillAgentDocument> {
|
||||
this.createCalls.push({ agentId, content, filename, params });
|
||||
|
||||
const id = `agent-doc-${this.nextId}`;
|
||||
const documentId = `document-${this.nextId}`;
|
||||
this.nextId += 1;
|
||||
|
||||
const doc = createAgentDocument({
|
||||
agentId,
|
||||
content,
|
||||
documentId,
|
||||
editorData: params?.editorData ?? null,
|
||||
fileType: params?.fileType ?? 'agent/document',
|
||||
filename,
|
||||
id,
|
||||
metadata: params?.metadata ?? null,
|
||||
parentId: params?.parentId ?? null,
|
||||
policyLoad: params?.policyLoad ?? PolicyLoad.PROGRESSIVE,
|
||||
source: params?.source ?? null,
|
||||
sourceType: params?.sourceType ?? 'agent',
|
||||
templateId: params?.templateId ?? null,
|
||||
title: params?.title ?? filename,
|
||||
});
|
||||
this.documents.push(doc);
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async findById(agentDocumentId: string): Promise<SkillAgentDocument | undefined> {
|
||||
return this.documents.find((doc) => doc.id === agentDocumentId && !doc.deletedAt);
|
||||
}
|
||||
|
||||
async findByDocumentId(
|
||||
agentId: string,
|
||||
documentId: string,
|
||||
): Promise<SkillAgentDocument | undefined> {
|
||||
return this.documents.find(
|
||||
(doc) => doc.agentId === agentId && doc.documentId === documentId && !doc.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async listByParent(agentId: string, parentId: string | null): Promise<SkillAgentDocument[]> {
|
||||
return this.documents.filter(
|
||||
(doc) => doc.agentId === agentId && doc.parentId === parentId && !doc.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async listByParentAndFilename(
|
||||
agentId: string,
|
||||
parentId: string | null,
|
||||
filename: string,
|
||||
): Promise<SkillAgentDocument[]> {
|
||||
return this.documents.filter(
|
||||
(doc) =>
|
||||
doc.agentId === agentId &&
|
||||
doc.parentId === parentId &&
|
||||
doc.filename === filename &&
|
||||
!doc.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async update(
|
||||
agentDocumentId: string,
|
||||
params?: {
|
||||
content?: string;
|
||||
editorData?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
policyLoad?: PolicyLoad;
|
||||
},
|
||||
): Promise<void> {
|
||||
this.updateCalls.push({ agentDocumentId, params });
|
||||
const existing = this.documents.find((doc) => doc.id === agentDocumentId);
|
||||
if (!existing || !params) return;
|
||||
|
||||
Object.assign(existing, params, { updatedAt: now });
|
||||
}
|
||||
|
||||
async updateDocumentIdentity(
|
||||
agentDocumentId: string,
|
||||
params: {
|
||||
filename?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
parentId?: string | null;
|
||||
title?: string;
|
||||
},
|
||||
): Promise<SkillAgentDocument | undefined> {
|
||||
this.identityUpdateCalls.push({ agentDocumentId, params });
|
||||
const existing = this.documents.find((doc) => doc.id === agentDocumentId);
|
||||
if (!existing) return undefined;
|
||||
|
||||
Object.assign(existing, params, { updatedAt: now });
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const createAgentDocument = (
|
||||
overrides: Partial<SkillAgentDocument> &
|
||||
Pick<SkillAgentDocument, 'agentId' | 'documentId' | 'id'>,
|
||||
): SkillAgentDocument => ({
|
||||
accessPublic: 0,
|
||||
accessSelf: 0,
|
||||
accessShared: 0,
|
||||
content: '',
|
||||
createdAt: now,
|
||||
deletedAt: null,
|
||||
deletedByAgentId: null,
|
||||
deletedByUserId: null,
|
||||
deleteReason: null,
|
||||
description: null,
|
||||
editorData: null,
|
||||
filename: 'document',
|
||||
fileType: 'agent/document',
|
||||
metadata: null,
|
||||
parentId: null,
|
||||
policy: null,
|
||||
policyLoad: PolicyLoad.PROGRESSIVE,
|
||||
policyLoadFormat: DocumentLoadFormat.RAW,
|
||||
policyLoadPosition: DocumentLoadPosition.BEFORE_FIRST_USER,
|
||||
policyLoadRule: DocumentLoadRule.ALWAYS,
|
||||
source: null,
|
||||
sourceType: 'agent',
|
||||
templateId: null,
|
||||
title: 'Document',
|
||||
updatedAt: now,
|
||||
userId: 'user-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createService = () => {
|
||||
const agentDocumentModel = new InMemoryAgentDocumentModel();
|
||||
const documentService = {
|
||||
trySaveCurrentDocumentHistory: vi.fn(async () => ({ savedAt: now })),
|
||||
};
|
||||
const service = new SkillManagementDocumentService(
|
||||
{
|
||||
transaction: async <T>(callback: (trx: Transaction) => Promise<T>) =>
|
||||
callback({} as Transaction),
|
||||
} as LobeChatDatabase,
|
||||
'user-1',
|
||||
{
|
||||
agentDocumentModel,
|
||||
createMarkdownEditorSnapshot: createSnapshot,
|
||||
documentService,
|
||||
},
|
||||
);
|
||||
|
||||
return { agentDocumentModel, documentService, service };
|
||||
};
|
||||
|
||||
const skillContent = (name: string, description: string, body = '# Skill') =>
|
||||
`---\ndescription: ${description}\nname: ${name}\n---\n${body}`;
|
||||
|
||||
const skillBody = (body = '# Skill') => body;
|
||||
|
||||
describe('SkillManagementDocumentService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates, lists, and gets managed skills without leaking list content', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
|
||||
await service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Writes release notes',
|
||||
name: 'release-writer',
|
||||
title: 'Release Writer',
|
||||
});
|
||||
await service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Reviews changes',
|
||||
name: 'code-reviewer',
|
||||
title: 'Code Reviewer',
|
||||
});
|
||||
|
||||
const list = await service.listSkills({ agentId: 'agent-1' });
|
||||
expect(list.map((item) => item.name)).toEqual(['code-reviewer', 'release-writer']);
|
||||
expect(list[0]).not.toHaveProperty('content');
|
||||
expect(agentDocumentModel.createCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
content: '',
|
||||
filename: 'release-writer',
|
||||
params: expect.objectContaining({
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: SKILL_MANAGEMENT_SOURCE,
|
||||
sourceType: SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const detail = await service.getSkill({
|
||||
agentId: 'agent-1',
|
||||
includeContent: true,
|
||||
name: 'release-writer',
|
||||
});
|
||||
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
content: skillContent('release-writer', 'Writes release notes'),
|
||||
frontmatter: { description: 'Writes release notes', name: 'release-writer' },
|
||||
name: 'release-writer',
|
||||
}),
|
||||
);
|
||||
expect(detail?.bundle.agentDocumentId).toBe('agent-doc-1');
|
||||
expect(detail?.bundle.documentId).toBe('document-1');
|
||||
expect(detail?.index.agentDocumentId).toBe('agent-doc-2');
|
||||
expect(detail?.index.documentId).toBe('document-2');
|
||||
});
|
||||
|
||||
it('converts a hinted source document into the index while preserving ids', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
const source = await agentDocumentModel.create('agent-1', 'draft-skill', '# Draft', {
|
||||
metadata: { agentSignal: { hintIsSkill: true } },
|
||||
title: 'Draft Skill',
|
||||
});
|
||||
|
||||
const detail = await service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody('# Draft'),
|
||||
description: 'Draft helper',
|
||||
name: 'draft-skill',
|
||||
sourceAgentDocumentId: source.id,
|
||||
title: 'Draft Skill',
|
||||
});
|
||||
|
||||
expect(detail.index.agentDocumentId).toBe(source.id);
|
||||
expect(detail.index.documentId).toBe(source.documentId);
|
||||
expect(detail.content).toBe(skillContent('draft-skill', 'Draft helper', '# Draft'));
|
||||
expect(agentDocumentModel.documents).toHaveLength(2);
|
||||
expect(agentDocumentModel.documents.find((doc) => doc.id === source.id)).toEqual(
|
||||
expect.objectContaining({
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
filename: SKILL_INDEX_FILENAME,
|
||||
parentId: detail.bundle.documentId,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: SKILL_MANAGEMENT_SOURCE,
|
||||
sourceType: SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate skill names before creating another bundle', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
await service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Review helper',
|
||||
name: 'review-skill',
|
||||
title: 'Review Skill',
|
||||
});
|
||||
agentDocumentModel.createCalls = [];
|
||||
|
||||
await expect(
|
||||
service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Review helper',
|
||||
name: 'review-skill',
|
||||
title: 'Review Skill',
|
||||
}),
|
||||
).rejects.toThrow('Skill already exists');
|
||||
|
||||
expect(agentDocumentModel.createCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not create a bundle when source document conversion cannot start', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
|
||||
await expect(
|
||||
service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Missing source',
|
||||
name: 'missing-skill',
|
||||
sourceAgentDocumentId: 'missing-source',
|
||||
title: 'Missing Skill',
|
||||
}),
|
||||
).rejects.toThrow('Source agent document not found: missing-source');
|
||||
|
||||
expect(agentDocumentModel.createCalls).toEqual([]);
|
||||
expect(await service.listSkills({ agentId: 'agent-1' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects source documents owned by another agent before creating a bundle', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
const otherAgentSource = await agentDocumentModel.create('agent-2', 'draft-skill', '# Draft', {
|
||||
metadata: { agentSignal: { hintIsSkill: true } },
|
||||
title: 'Draft Skill',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Draft helper',
|
||||
name: 'draft-skill',
|
||||
sourceAgentDocumentId: otherAgentSource.id,
|
||||
title: 'Draft Skill',
|
||||
}),
|
||||
).rejects.toThrow('Source agent document does not belong to agent agent-1');
|
||||
|
||||
expect(agentDocumentModel.createCalls).toHaveLength(1);
|
||||
expect(await service.listSkills({ agentId: 'agent-1' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces the skill index and saves history with the backing document id', async () => {
|
||||
const { agentDocumentModel, documentService, service } = createService();
|
||||
const created = await service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Researches APIs',
|
||||
name: 'researcher',
|
||||
title: 'Researcher',
|
||||
});
|
||||
|
||||
const detail = await service.replaceSkillIndex({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody('# Better'),
|
||||
description: 'Researches docs better',
|
||||
name: 'researcher',
|
||||
});
|
||||
|
||||
expect(documentService.trySaveCurrentDocumentHistory).toHaveBeenCalledWith(
|
||||
created.index.documentId,
|
||||
'llm_call',
|
||||
);
|
||||
expect(detail?.content).toBe(skillContent('researcher', 'Researches docs better', '# Better'));
|
||||
expect(detail?.frontmatter).toEqual({
|
||||
description: 'Researches docs better',
|
||||
name: 'researcher',
|
||||
});
|
||||
expect(agentDocumentModel.updateCalls.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
agentDocumentId: created.index.agentDocumentId,
|
||||
params: expect.objectContaining({
|
||||
metadata: {
|
||||
skill: { frontmatter: { description: 'Researches docs better', name: 'researcher' } },
|
||||
},
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(agentDocumentModel.identityUpdateCalls.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
agentDocumentId: created.bundle.agentDocumentId,
|
||||
params: {
|
||||
metadata: {
|
||||
skill: { frontmatter: { description: 'Researches docs better', name: 'researcher' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a target skill from either the bundle or index agent document id', async () => {
|
||||
const { service } = createService();
|
||||
const created = await service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Portable lookup',
|
||||
name: 'portable-skill',
|
||||
title: 'Portable Skill',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getSkill({
|
||||
agentDocumentId: created.index.agentDocumentId,
|
||||
agentId: 'agent-1',
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
bundle: created.bundle,
|
||||
index: created.index,
|
||||
name: 'portable-skill',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renames bundle identity and synchronizes index frontmatter content', async () => {
|
||||
const { agentDocumentModel, documentService, service } = createService();
|
||||
const created = await service.createSkill({
|
||||
agentId: 'agent-1',
|
||||
bodyMarkdown: skillBody(),
|
||||
description: 'Old description',
|
||||
name: 'old-skill',
|
||||
title: 'Old Skill',
|
||||
});
|
||||
|
||||
const detail = await service.renameSkill({
|
||||
agentDocumentId: created.bundle.agentDocumentId,
|
||||
agentId: 'agent-1',
|
||||
newName: 'new-skill',
|
||||
newTitle: 'New Skill',
|
||||
});
|
||||
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
content: skillContent('new-skill', 'Old description'),
|
||||
frontmatter: { description: 'Old description', name: 'new-skill' },
|
||||
name: 'new-skill',
|
||||
title: 'New Skill',
|
||||
}),
|
||||
);
|
||||
expect(agentDocumentModel.identityUpdateCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
agentDocumentId: created.bundle.agentDocumentId,
|
||||
params: expect.objectContaining({
|
||||
filename: 'new-skill',
|
||||
title: 'New Skill',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agentDocumentId: created.index.agentDocumentId,
|
||||
params: expect.objectContaining({
|
||||
filename: SKILL_INDEX_FILENAME,
|
||||
title: SKILL_INDEX_FILENAME,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(documentService.trySaveCurrentDocumentHistory).toHaveBeenCalledWith(
|
||||
created.index.documentId,
|
||||
'llm_call',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws loudly when a bundle has no matching index document', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
await agentDocumentModel.create('agent-1', 'broken-skill', '', {
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
title: 'Broken Skill',
|
||||
});
|
||||
|
||||
await expect(service.listSkills({ agentId: 'agent-1' })).rejects.toThrow(
|
||||
'expected one SKILL.md index, found 0',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws loudly when a bundle has multiple matching index documents', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
const bundle = await agentDocumentModel.create('agent-1', 'broken-skill', '', {
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
title: 'Broken Skill',
|
||||
});
|
||||
|
||||
await agentDocumentModel.create(
|
||||
'agent-1',
|
||||
SKILL_INDEX_FILENAME,
|
||||
skillContent('broken-skill', 'One'),
|
||||
{
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
parentId: bundle.documentId,
|
||||
},
|
||||
);
|
||||
await agentDocumentModel.create(
|
||||
'agent-1',
|
||||
SKILL_INDEX_FILENAME,
|
||||
skillContent('broken-skill', 'Two'),
|
||||
{
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
parentId: bundle.documentId,
|
||||
},
|
||||
);
|
||||
|
||||
await expect(service.getSkill({ agentId: 'agent-1', name: 'broken-skill' })).rejects.toThrow(
|
||||
'expected one SKILL.md index, found 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws loudly when duplicate root bundles share the same skill name', async () => {
|
||||
const { agentDocumentModel, service } = createService();
|
||||
const firstBundle = await agentDocumentModel.create('agent-1', 'duplicate-skill', '', {
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
title: 'Duplicate Skill',
|
||||
});
|
||||
const secondBundle = await agentDocumentModel.create('agent-1', 'duplicate-skill', '', {
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
title: 'Duplicate Skill Copy',
|
||||
});
|
||||
|
||||
await agentDocumentModel.create(
|
||||
'agent-1',
|
||||
SKILL_INDEX_FILENAME,
|
||||
skillContent('duplicate-skill', 'One'),
|
||||
{
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
parentId: firstBundle.documentId,
|
||||
},
|
||||
);
|
||||
await agentDocumentModel.create(
|
||||
'agent-1',
|
||||
SKILL_INDEX_FILENAME,
|
||||
skillContent('duplicate-skill', 'Two'),
|
||||
{
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
parentId: secondBundle.documentId,
|
||||
},
|
||||
);
|
||||
|
||||
await expect(service.listSkills({ agentId: 'agent-1' })).rejects.toThrow(
|
||||
'duplicate bundle names duplicate-skill',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
|
||||
import { AgentDocumentModel, PolicyLoad } from '@/database/models/agentDocuments';
|
||||
|
||||
import type { AgentDocumentEditorSnapshot } from '../agentDocuments/headlessEditor';
|
||||
import { DocumentService } from '../document';
|
||||
import {
|
||||
AGENT_SKILL_TEMPLATE_ID,
|
||||
SKILL_BUNDLE_FILE_TYPE,
|
||||
SKILL_INDEX_FILE_TYPE,
|
||||
SKILL_INDEX_FILENAME,
|
||||
SKILL_MANAGEMENT_SOURCE,
|
||||
SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
} from './constants';
|
||||
import {
|
||||
normalizeSkillIndexContent,
|
||||
parseSkillFrontmatter,
|
||||
renderSkillIndexContent,
|
||||
validateSkillName,
|
||||
} from './frontmatter';
|
||||
import type {
|
||||
CreateSkillInput,
|
||||
GetSkillInput,
|
||||
ListSkillsInput,
|
||||
RenameSkillInput,
|
||||
ReplaceSkillIndexInput,
|
||||
SkillAgentDocument,
|
||||
SkillDetail,
|
||||
SkillDocumentRef,
|
||||
SkillSummary,
|
||||
SkillTargetInput,
|
||||
} from './types';
|
||||
|
||||
type SkillManagementAgentDocumentModel = Pick<
|
||||
AgentDocumentModel,
|
||||
| 'convertAgentDocumentToSkillIndex'
|
||||
| 'convertAgentDocumentToSkillIndexWithTx'
|
||||
| 'create'
|
||||
| 'createWithTx'
|
||||
| 'findByDocumentId'
|
||||
| 'findById'
|
||||
| 'listByParent'
|
||||
| 'listByParentAndFilename'
|
||||
| 'update'
|
||||
| 'updateDocumentIdentity'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Optional dependency overrides used by focused service tests and alternate runtimes.
|
||||
*/
|
||||
interface SkillManagementDocumentServiceDeps {
|
||||
/** Agent document persistence adapter. */
|
||||
agentDocumentModel: SkillManagementAgentDocumentModel;
|
||||
/** Markdown-to-editor snapshot projector. */
|
||||
createMarkdownEditorSnapshot?: (content: string) => Promise<AgentDocumentEditorSnapshot>;
|
||||
/** Document history service dependency. */
|
||||
documentService: Pick<DocumentService, 'trySaveCurrentDocumentHistory'>;
|
||||
}
|
||||
|
||||
const createEmptyEditorData = (): Record<string, unknown> => ({
|
||||
root: { children: [], type: 'root' },
|
||||
});
|
||||
|
||||
const toDocumentRef = (doc: SkillAgentDocument): SkillDocumentRef => ({
|
||||
agentDocumentId: doc.id,
|
||||
documentId: doc.documentId,
|
||||
filename: doc.filename,
|
||||
title: doc.title,
|
||||
});
|
||||
|
||||
const buildSkillMetadata = (
|
||||
frontmatter: ReturnType<typeof parseSkillFrontmatter>,
|
||||
): Record<string, unknown> => ({
|
||||
skill: { frontmatter },
|
||||
});
|
||||
|
||||
/**
|
||||
* Owns managed skill bundle and index document invariants.
|
||||
*
|
||||
* Use when:
|
||||
* - Creating, reading, replacing, or renaming Agent Signal managed skills.
|
||||
* - Callers need stable `agentDocumentId` and backing `documentId` semantics.
|
||||
*
|
||||
* Expects:
|
||||
* - Managed skills are represented as a `skills/bundle` parent document and one `skills/index` child.
|
||||
* - `agentDocumentId` values refer to `agent_documents.id`.
|
||||
*
|
||||
* Returns:
|
||||
* - Skill summaries/details that preserve both binding ids and backing document ids.
|
||||
*/
|
||||
export class SkillManagementDocumentService {
|
||||
private agentDocumentModel: SkillManagementAgentDocumentModel;
|
||||
private documentService: Pick<DocumentService, 'trySaveCurrentDocumentHistory'>;
|
||||
|
||||
constructor(
|
||||
private db: LobeChatDatabase,
|
||||
userId: string,
|
||||
deps?: SkillManagementDocumentServiceDeps,
|
||||
) {
|
||||
this.agentDocumentModel = deps?.agentDocumentModel ?? new AgentDocumentModel(db, userId);
|
||||
this.documentService = deps?.documentService ?? new DocumentService(db, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a managed skill bundle and its SKILL.md index.
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal needs to persist a new managed skill.
|
||||
* - A hinted ordinary agent document should be converted into the index while keeping ids.
|
||||
*
|
||||
* Expects:
|
||||
* - `name` is a stable lowercase skill name.
|
||||
* - `bodyMarkdown` is Markdown body content without YAML frontmatter.
|
||||
*
|
||||
* Returns:
|
||||
* - The created skill detail with normalized frontmatter content.
|
||||
*/
|
||||
async createSkill(input: CreateSkillInput): Promise<SkillDetail> {
|
||||
const name = validateSkillName(input.name);
|
||||
const normalizedContent = renderSkillIndexContent({
|
||||
bodyMarkdown: input.bodyMarkdown,
|
||||
description: input.description,
|
||||
name,
|
||||
});
|
||||
const frontmatter = parseSkillFrontmatter(normalizedContent);
|
||||
const metadata = buildSkillMetadata(frontmatter);
|
||||
const duplicateBundles = (
|
||||
await this.agentDocumentModel.listByParentAndFilename(input.agentId, null, name)
|
||||
).filter((doc) => doc.fileType === SKILL_BUNDLE_FILE_TYPE);
|
||||
|
||||
if (duplicateBundles.length > 0) {
|
||||
throw new Error('Skill already exists');
|
||||
}
|
||||
|
||||
// REVIEW(@nekomeowww):
|
||||
// Direct Agent Document VFS writes, including `lb agent space fs`, can create skill-shaped
|
||||
// bundle/index documents without going through this service. Should we support importing or
|
||||
// normalizing those compatibility documents here so they become first-class managed skills?
|
||||
const sourceDocument = input.sourceAgentDocumentId
|
||||
? await this.agentDocumentModel.findById(input.sourceAgentDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (input.sourceAgentDocumentId && !sourceDocument) {
|
||||
throw new Error(`Source agent document not found: ${input.sourceAgentDocumentId}`);
|
||||
}
|
||||
|
||||
if (sourceDocument && sourceDocument.agentId !== input.agentId) {
|
||||
throw new Error(
|
||||
`Source agent document does not belong to agent ${input.agentId}: ${input.sourceAgentDocumentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { bundle, index } = await this.db.transaction(async (trx) => {
|
||||
const bundle = await this.agentDocumentModel.createWithTx(trx, input.agentId, name, '', {
|
||||
editorData: createEmptyEditorData(),
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
metadata,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: SKILL_MANAGEMENT_SOURCE,
|
||||
sourceType: SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: input.title,
|
||||
});
|
||||
|
||||
if (input.sourceAgentDocumentId) {
|
||||
const index = await this.agentDocumentModel.convertAgentDocumentToSkillIndexWithTx(trx, {
|
||||
agentDocumentId: input.sourceAgentDocumentId,
|
||||
content: normalizedContent,
|
||||
filename: SKILL_INDEX_FILENAME,
|
||||
metadata,
|
||||
parentId: bundle.documentId,
|
||||
source: SKILL_MANAGEMENT_SOURCE,
|
||||
sourceType: SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
title: SKILL_INDEX_FILENAME,
|
||||
});
|
||||
|
||||
if (!index) {
|
||||
throw new Error(`Source agent document not found: ${input.sourceAgentDocumentId}`);
|
||||
}
|
||||
|
||||
return { bundle, index };
|
||||
}
|
||||
|
||||
const index = await this.agentDocumentModel.createWithTx(
|
||||
trx,
|
||||
input.agentId,
|
||||
SKILL_INDEX_FILENAME,
|
||||
normalizedContent,
|
||||
{
|
||||
fileType: SKILL_INDEX_FILE_TYPE,
|
||||
metadata,
|
||||
parentId: bundle.documentId,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
source: SKILL_MANAGEMENT_SOURCE,
|
||||
sourceType: SKILL_MANAGEMENT_SOURCE_TYPE,
|
||||
templateId: AGENT_SKILL_TEMPLATE_ID,
|
||||
title: SKILL_INDEX_FILENAME,
|
||||
},
|
||||
);
|
||||
|
||||
return { bundle, index };
|
||||
});
|
||||
|
||||
return this.toSkillDetail(bundle, index, { includeContent: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists managed skills owned by an agent.
|
||||
*
|
||||
* Use when:
|
||||
* - Rendering a skill picker or preparing worker context.
|
||||
* - Callers need summaries without index document content.
|
||||
*
|
||||
* Expects:
|
||||
* - Corrupt bundles should be surfaced as errors instead of hidden.
|
||||
*
|
||||
* Returns:
|
||||
* - Skill summaries sorted by stable skill name.
|
||||
*/
|
||||
async listSkills(input: ListSkillsInput): Promise<SkillSummary[]> {
|
||||
const bundles = (await this.agentDocumentModel.listByParent(input.agentId, null)).filter(
|
||||
(doc) => doc.fileType === SKILL_BUNDLE_FILE_TYPE,
|
||||
);
|
||||
const duplicateNames = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
for (const bundle of bundles) {
|
||||
if (seenNames.has(bundle.filename)) duplicateNames.add(bundle.filename);
|
||||
seenNames.add(bundle.filename);
|
||||
}
|
||||
|
||||
if (duplicateNames.size > 0) {
|
||||
throw new Error(
|
||||
`Corrupt managed skills: duplicate bundle names ${[...duplicateNames].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const summaries = await Promise.all(
|
||||
bundles.map(async (bundle) => {
|
||||
const index = await this.getSingleIndex(input.agentId, bundle);
|
||||
return this.toSkillSummary(bundle, index);
|
||||
}),
|
||||
);
|
||||
|
||||
return summaries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one managed skill by stable name or agent document binding id.
|
||||
*
|
||||
* Use when:
|
||||
* - A tool call targets a skill by name.
|
||||
* - A tool outcome references a known `agentDocumentId`.
|
||||
*
|
||||
* Expects:
|
||||
* - Exactly one identifier is useful; `agentDocumentId` takes precedence when both are present.
|
||||
*
|
||||
* Returns:
|
||||
* - The matching skill detail, or `undefined` when no bundle is found.
|
||||
*/
|
||||
async getSkill(input: GetSkillInput): Promise<SkillDetail | undefined> {
|
||||
const resolved = await this.resolveBundle(input.agentId, input);
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const index = await this.getSingleIndex(input.agentId, resolved);
|
||||
return this.toSkillDetail(resolved, index, { includeContent: input.includeContent });
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a managed skill index while preserving document ids.
|
||||
*
|
||||
* Use when:
|
||||
* - Agent Signal refines an existing skill.
|
||||
* - The current backing document needs a history snapshot before mutation.
|
||||
*
|
||||
* Expects:
|
||||
* - The target resolves to a live managed skill bundle.
|
||||
* - Incoming bodyMarkdown has no YAML frontmatter.
|
||||
*
|
||||
* Returns:
|
||||
* - Updated skill detail with index content included.
|
||||
*/
|
||||
async replaceSkillIndex(input: ReplaceSkillIndexInput): Promise<SkillDetail | undefined> {
|
||||
const resolved = await this.resolveBundle(input.agentId, input);
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const index = await this.getSingleIndex(input.agentId, resolved);
|
||||
const description = input.description ?? parseSkillFrontmatter(index.content).description;
|
||||
const normalizedContent = renderSkillIndexContent({
|
||||
bodyMarkdown: input.bodyMarkdown,
|
||||
description,
|
||||
name: resolved.filename,
|
||||
});
|
||||
const frontmatter = parseSkillFrontmatter(normalizedContent);
|
||||
const metadata = buildSkillMetadata(frontmatter);
|
||||
|
||||
await this.documentService.trySaveCurrentDocumentHistory(index.documentId, 'llm_call');
|
||||
const updatedBundle =
|
||||
(await this.agentDocumentModel.updateDocumentIdentity(resolved.id, {
|
||||
metadata,
|
||||
})) ?? resolved;
|
||||
await this.agentDocumentModel.update(index.id, {
|
||||
content: normalizedContent,
|
||||
metadata,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
});
|
||||
|
||||
const updated = await this.agentDocumentModel.findById(index.id);
|
||||
if (!updated) throw new Error(`Skill index disappeared during replace: ${index.id}`);
|
||||
|
||||
return this.toSkillDetail(updatedBundle, updated, { includeContent: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a managed skill bundle and synchronizes its index frontmatter projection.
|
||||
*
|
||||
* Use when:
|
||||
* - A caller changes the stable skill name or human-readable bundle title.
|
||||
* - Existing document ids and history must be preserved.
|
||||
*
|
||||
* Expects:
|
||||
* - The target resolves to a live managed skill bundle.
|
||||
* - `newName`, when provided, is a valid stable skill name.
|
||||
*
|
||||
* Returns:
|
||||
* - Updated skill detail with content included.
|
||||
*/
|
||||
async renameSkill(input: RenameSkillInput): Promise<SkillDetail | undefined> {
|
||||
const resolved = await this.resolveBundle(input.agentId, input);
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const index = await this.getSingleIndex(input.agentId, resolved);
|
||||
const name = input.newName ? validateSkillName(input.newName) : resolved.filename;
|
||||
const title = input.newTitle?.trim() || resolved.title;
|
||||
const normalizedContent = normalizeSkillIndexContent({
|
||||
bundleName: name,
|
||||
content: index.content,
|
||||
});
|
||||
const frontmatter = parseSkillFrontmatter(normalizedContent);
|
||||
const metadata = buildSkillMetadata(frontmatter);
|
||||
|
||||
if (normalizedContent !== index.content) {
|
||||
await this.documentService.trySaveCurrentDocumentHistory(index.documentId, 'llm_call');
|
||||
}
|
||||
|
||||
const updatedBundle =
|
||||
(await this.agentDocumentModel.updateDocumentIdentity(resolved.id, {
|
||||
filename: name,
|
||||
metadata,
|
||||
title,
|
||||
})) ?? resolved;
|
||||
|
||||
await this.agentDocumentModel.updateDocumentIdentity(index.id, {
|
||||
filename: SKILL_INDEX_FILENAME,
|
||||
metadata,
|
||||
title: SKILL_INDEX_FILENAME,
|
||||
});
|
||||
await this.agentDocumentModel.update(index.id, {
|
||||
content: normalizedContent,
|
||||
metadata,
|
||||
policyLoad: PolicyLoad.DISABLED,
|
||||
});
|
||||
|
||||
const updatedIndex = await this.agentDocumentModel.findById(index.id);
|
||||
if (!updatedIndex) throw new Error(`Skill index disappeared during rename: ${index.id}`);
|
||||
|
||||
return this.toSkillDetail(updatedBundle, updatedIndex, { includeContent: true });
|
||||
}
|
||||
|
||||
private async resolveBundle(
|
||||
agentId: string,
|
||||
target: SkillTargetInput,
|
||||
): Promise<SkillAgentDocument | undefined> {
|
||||
if (target.agentDocumentId) {
|
||||
const doc = await this.agentDocumentModel.findById(target.agentDocumentId);
|
||||
if (!doc) return undefined;
|
||||
if (doc.agentId !== agentId) return undefined;
|
||||
if (doc.fileType === SKILL_BUNDLE_FILE_TYPE) return doc;
|
||||
|
||||
if (doc.fileType === SKILL_INDEX_FILE_TYPE && doc.parentId) {
|
||||
const bundle = await this.agentDocumentModel.findByDocumentId(agentId, doc.parentId);
|
||||
if (bundle?.fileType === SKILL_BUNDLE_FILE_TYPE) return bundle;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!target.name) return undefined;
|
||||
|
||||
const name = validateSkillName(target.name);
|
||||
const matches = (
|
||||
await this.agentDocumentModel.listByParentAndFilename(agentId, null, name)
|
||||
).filter((doc) => doc.fileType === SKILL_BUNDLE_FILE_TYPE);
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`Corrupt managed skill bundle "${name}": expected one bundle, found ${matches.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
private async getSingleIndex(
|
||||
agentId: string,
|
||||
bundle: SkillAgentDocument,
|
||||
): Promise<SkillAgentDocument> {
|
||||
const indexes = (
|
||||
await this.agentDocumentModel.listByParentAndFilename(
|
||||
agentId,
|
||||
bundle.documentId,
|
||||
SKILL_INDEX_FILENAME,
|
||||
)
|
||||
).filter((doc) => doc.fileType === SKILL_INDEX_FILE_TYPE);
|
||||
|
||||
if (indexes.length !== 1) {
|
||||
throw new Error(
|
||||
`Corrupt managed skill bundle "${bundle.filename}": expected one ${SKILL_INDEX_FILENAME} index, found ${indexes.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return indexes[0]!;
|
||||
}
|
||||
|
||||
private toSkillSummary(bundle: SkillAgentDocument, index: SkillAgentDocument): SkillSummary {
|
||||
const frontmatter = parseSkillFrontmatter(index.content);
|
||||
|
||||
return {
|
||||
bundle: toDocumentRef(bundle),
|
||||
description: frontmatter.description,
|
||||
index: toDocumentRef(index),
|
||||
name: bundle.filename,
|
||||
title: bundle.title,
|
||||
};
|
||||
}
|
||||
|
||||
private toSkillDetail(
|
||||
bundle: SkillAgentDocument,
|
||||
index: SkillAgentDocument,
|
||||
options?: { includeContent?: boolean },
|
||||
): SkillDetail {
|
||||
const frontmatter = parseSkillFrontmatter(index.content);
|
||||
|
||||
return {
|
||||
...this.toSkillSummary(bundle, index),
|
||||
...(options?.includeContent && { content: index.content }),
|
||||
frontmatter,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type { SkillManagementAgentDocumentModel, SkillManagementDocumentServiceDeps };
|
||||
@@ -0,0 +1,17 @@
|
||||
/** File type used by the parent document for a managed skill bundle. */
|
||||
export const SKILL_BUNDLE_FILE_TYPE = 'skills/bundle';
|
||||
|
||||
/** File type used by the SKILL.md index document inside a managed skill bundle. */
|
||||
export const SKILL_INDEX_FILE_TYPE = 'skills/index';
|
||||
|
||||
/** Source attribution stored on documents created by skill-management tooling. */
|
||||
export const SKILL_MANAGEMENT_SOURCE = 'agent-signal:skill-management';
|
||||
|
||||
/** Source type stored on documents created by Agent Signal skill-management tooling. */
|
||||
export const SKILL_MANAGEMENT_SOURCE_TYPE = 'agent-signal';
|
||||
|
||||
/** Canonical filename for a skill index document. */
|
||||
export const SKILL_INDEX_FILENAME = 'SKILL.md';
|
||||
|
||||
/** Template id applied to agent document bindings that represent managed skills. */
|
||||
export const AGENT_SKILL_TEMPLATE_ID = 'agent-skill';
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeSkillIndexContent,
|
||||
parseSkillFrontmatter,
|
||||
validateSkillName,
|
||||
} from './frontmatter';
|
||||
|
||||
describe('validateSkillName', () => {
|
||||
it('accepts lowercase hyphenated skill names', () => {
|
||||
expect(validateSkillName('pr-review-checklist')).toBe('pr-review-checklist');
|
||||
expect(validateSkillName('skill-1')).toBe('skill-1');
|
||||
});
|
||||
|
||||
it('rejects path-like skill names', () => {
|
||||
expect(() => validateSkillName('../secret')).toThrow('Invalid skill name');
|
||||
expect(() => validateSkillName('skill/name')).toThrow('Invalid skill name');
|
||||
expect(() => validateSkillName('skill\\name')).toThrow('Invalid skill name');
|
||||
expect(() => validateSkillName('SkillName')).toThrow('Invalid skill name');
|
||||
});
|
||||
|
||||
it('rejects empty and too-long skill names', () => {
|
||||
expect(() => validateSkillName('')).toThrow('Invalid skill name');
|
||||
expect(() => validateSkillName('a'.repeat(81))).toThrow('Invalid skill name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSkillFrontmatter', () => {
|
||||
it('parses name and description', () => {
|
||||
expect(
|
||||
parseSkillFrontmatter('---\nname: old-name\ndescription: Old description\n---\nBody'),
|
||||
).toEqual({ description: 'Old description', name: 'old-name' });
|
||||
});
|
||||
|
||||
it('parses YAML descriptions with quotes, colons, block scalars, and CRLF', () => {
|
||||
expect(
|
||||
parseSkillFrontmatter('---\r\nname: old-name\r\ndescription: "Review: PRs"\r\n---\r\nBody'),
|
||||
).toEqual({ description: 'Review: PRs', name: 'old-name' });
|
||||
|
||||
expect(
|
||||
parseSkillFrontmatter('---\nname: old-name\ndescription: "Review pull requests"\n---\nBody'),
|
||||
).toEqual({ description: 'Review pull requests', name: 'old-name' });
|
||||
});
|
||||
|
||||
it('rejects missing frontmatter', () => {
|
||||
expect(() =>
|
||||
parseSkillFrontmatter('name: old-name\ndescription: Old description\nBody'),
|
||||
).toThrow('Skill index content must start with frontmatter');
|
||||
});
|
||||
|
||||
it('rejects missing description', () => {
|
||||
expect(() => parseSkillFrontmatter('---\nname: old-name\n---\nBody')).toThrow(
|
||||
'Skill frontmatter description is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects multiline parsed descriptions', () => {
|
||||
expect(() =>
|
||||
parseSkillFrontmatter('---\nname: old-name\ndescription: |\n Line 1\n Line 2\n---\nBody'),
|
||||
).toThrow('Skill frontmatter description must be a single-line scalar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSkillIndexContent', () => {
|
||||
it('rewrites the frontmatter name from the bundle filename and keeps the body', () => {
|
||||
expect(
|
||||
normalizeSkillIndexContent({
|
||||
bundleName: 'new-name',
|
||||
content: '---\nname: old-name\ndescription: Keep me\n---\n# Body\n- Step',
|
||||
}),
|
||||
).toBe('---\nname: new-name\ndescription: Keep me\n---\n# Body\n- Step');
|
||||
});
|
||||
|
||||
it('uses explicit description when provided', () => {
|
||||
expect(
|
||||
normalizeSkillIndexContent({
|
||||
bundleName: 'new-name',
|
||||
content: '---\nname: old-name\ndescription: Old\n---\nBody',
|
||||
description: 'New description',
|
||||
}),
|
||||
).toBe('---\nname: new-name\ndescription: New description\n---\nBody');
|
||||
});
|
||||
|
||||
it('rewrites stale invalid frontmatter name from the canonical bundle filename', () => {
|
||||
expect(
|
||||
parseSkillFrontmatter(
|
||||
normalizeSkillIndexContent({
|
||||
bundleName: 'new-name',
|
||||
content: '---\nname: Old Skill\ndescription: Keep me\n---\nBody',
|
||||
}),
|
||||
),
|
||||
).toEqual({ description: 'Keep me', name: 'new-name' });
|
||||
});
|
||||
|
||||
it('rejects multiline description overrides before writing frontmatter', () => {
|
||||
expect(() =>
|
||||
normalizeSkillIndexContent({
|
||||
bundleName: 'new-name',
|
||||
content: '---\nname: old-name\ndescription: Old\n---\nBody',
|
||||
description: 'Injected\nname: other-name',
|
||||
}),
|
||||
).toThrow('Skill frontmatter description must be a single-line scalar');
|
||||
});
|
||||
|
||||
it('rejects blank description overrides instead of falling back', () => {
|
||||
expect(() =>
|
||||
normalizeSkillIndexContent({
|
||||
bundleName: 'new-name',
|
||||
content: '---\nname: old-name\ndescription: Old\n---\nBody',
|
||||
description: ' ',
|
||||
}),
|
||||
).toThrow('Skill frontmatter description is required');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import matter from 'gray-matter';
|
||||
|
||||
/**
|
||||
* Parsed metadata from a managed skill `SKILL.md` frontmatter block.
|
||||
*/
|
||||
export interface SkillFrontmatter {
|
||||
/** Human-readable description used for listings and document metadata. */
|
||||
description: string;
|
||||
/** Stable lowercase skill package name. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input required to normalize a managed skill index document.
|
||||
*/
|
||||
export interface NormalizeSkillIndexContentInput {
|
||||
/** Stable bundle filename that must become the frontmatter `name`. */
|
||||
bundleName: string;
|
||||
/** Raw `SKILL.md` content with frontmatter and body. */
|
||||
content: string;
|
||||
/** Optional replacement description. Keeps the current frontmatter description when omitted. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input required to render a managed skill index from structured fields.
|
||||
*/
|
||||
export interface RenderSkillIndexContentInput {
|
||||
/** Markdown body authored by the skill-management agent. */
|
||||
bodyMarkdown: string;
|
||||
/** Frontmatter description rendered by the service. */
|
||||
description: string;
|
||||
/** Stable bundle filename rendered as frontmatter `name`. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ParsedMatterResult {
|
||||
body: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MAX_SKILL_NAME_LENGTH = 80;
|
||||
const UNSAFE_FRONTMATTER_SCALAR_PATTERN = /[\r\n]/;
|
||||
const FRONTMATTER_BLOCK_PATTERN = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/;
|
||||
|
||||
/**
|
||||
* Validates a stable skill package name.
|
||||
*
|
||||
* Use when:
|
||||
* - Creating or renaming managed skill bundles.
|
||||
* - Projecting a managed skill into VFS paths.
|
||||
*
|
||||
* Expects:
|
||||
* - The input is a single lowercase path segment.
|
||||
*
|
||||
* Returns:
|
||||
* - The trimmed stable skill name.
|
||||
*/
|
||||
export const validateSkillName = (value: string): string => {
|
||||
const name = value.trim();
|
||||
|
||||
if (
|
||||
!name ||
|
||||
name.length > MAX_SKILL_NAME_LENGTH ||
|
||||
!SKILL_NAME_PATTERN.test(name) ||
|
||||
name.includes('/') ||
|
||||
name.includes('\\') ||
|
||||
name === '.' ||
|
||||
name === '..'
|
||||
) {
|
||||
throw new Error('Invalid skill name: expected lowercase letters, digits, and hyphens');
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
const parseMatter = (content: string): ParsedMatterResult => {
|
||||
const normalizedContent = content.trimStart();
|
||||
|
||||
if (!normalizedContent.startsWith('---')) {
|
||||
throw new Error('Skill index content must start with frontmatter');
|
||||
}
|
||||
|
||||
if (!FRONTMATTER_BLOCK_PATTERN.test(normalizedContent)) {
|
||||
throw new Error('Skill index content must close frontmatter');
|
||||
}
|
||||
|
||||
const parsed = matter(normalizedContent);
|
||||
|
||||
return { body: parsed.content, data: parsed.data };
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders skill index content from structured metadata and body Markdown.
|
||||
*
|
||||
* Before:
|
||||
* - bodyMarkdown: "# Review\n\n## Workflow\n- Check tests."
|
||||
*
|
||||
* After:
|
||||
* - "---\nname: review\ndescription: Review PRs\n---\n# Review\n\n## Workflow\n- Check tests."
|
||||
*/
|
||||
export const renderSkillIndexContent = (input: RenderSkillIndexContentInput): string => {
|
||||
const name = validateSkillName(input.name);
|
||||
const description = normalizeFrontmatterScalar(input.description, 'description');
|
||||
const body = input.bodyMarkdown.trimStart();
|
||||
|
||||
if (!body) {
|
||||
throw new Error('Skill bodyMarkdown is required');
|
||||
}
|
||||
|
||||
if (body.startsWith('---')) {
|
||||
throw new Error('Skill bodyMarkdown must not include YAML frontmatter');
|
||||
}
|
||||
|
||||
return matter
|
||||
.stringify(body, {
|
||||
description,
|
||||
name,
|
||||
})
|
||||
.replace(/\n$/, body.endsWith('\n') ? '\n' : '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses skill index frontmatter.
|
||||
*
|
||||
* Use when:
|
||||
* - Validating incoming skill content before persistence.
|
||||
* - Updating `metadata.skill.frontmatter` from normalized content.
|
||||
*
|
||||
* Expects:
|
||||
* - YAML-like `name` and `description` scalar lines.
|
||||
*
|
||||
* Returns:
|
||||
* - Parsed frontmatter fields.
|
||||
*/
|
||||
export const parseSkillFrontmatter = (content: string): SkillFrontmatter => {
|
||||
const { data } = parseMatter(content);
|
||||
const { description, name } = readSkillFrontmatterFields(data);
|
||||
|
||||
if (!name) throw new Error('Skill frontmatter name is required');
|
||||
if (!description) throw new Error('Skill frontmatter description is required');
|
||||
|
||||
return {
|
||||
description: normalizeFrontmatterScalar(description, 'description'),
|
||||
name: validateSkillName(name),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes skill index content.
|
||||
*
|
||||
* Before:
|
||||
* - "---\nname: old-name\ndescription: Old\n---\nBody"
|
||||
*
|
||||
* After:
|
||||
* - "---\nname: new-name\ndescription: Old\n---\nBody"
|
||||
*/
|
||||
export const normalizeSkillIndexContent = (input: NormalizeSkillIndexContentInput): string => {
|
||||
const bundleName = validateSkillName(input.bundleName);
|
||||
const { body, data } = parseMatter(input.content);
|
||||
const { description: parsedDescription } = readSkillFrontmatterFields(data);
|
||||
const description =
|
||||
input.description === undefined
|
||||
? parsedDescription
|
||||
: normalizeFrontmatterScalar(input.description, 'description');
|
||||
|
||||
if (!description) {
|
||||
throw new Error('Skill frontmatter description is required');
|
||||
}
|
||||
|
||||
const serialized = matter.stringify(body, {
|
||||
...data,
|
||||
description: normalizeFrontmatterScalar(description, 'description'),
|
||||
name: bundleName,
|
||||
});
|
||||
|
||||
return body.endsWith('\n') ? serialized : serialized.replace(/\n$/, '');
|
||||
};
|
||||
|
||||
const readSkillFrontmatterFields = (data: Record<string, unknown>): Partial<SkillFrontmatter> => {
|
||||
const name = typeof data.name === 'string' ? data.name.trim() : undefined;
|
||||
const description = typeof data.description === 'string' ? data.description.trim() : undefined;
|
||||
|
||||
return { description, name };
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes frontmatter scalar values before writing them through YAML serialization.
|
||||
*
|
||||
* Before:
|
||||
* - "Review PRs\nname: injected"
|
||||
*
|
||||
* After:
|
||||
* - throws "Skill frontmatter description must be a single-line scalar"
|
||||
*/
|
||||
export const normalizeFrontmatterScalar = (value: string, field: string): string => {
|
||||
const normalized = value.trim();
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error(`Skill frontmatter ${field} is required`);
|
||||
}
|
||||
|
||||
if (UNSAFE_FRONTMATTER_SCALAR_PATTERN.test(normalized)) {
|
||||
throw new Error(`Skill frontmatter ${field} must be a single-line scalar`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './constants';
|
||||
export * from './frontmatter';
|
||||
export * from './SkillManagementDocumentService';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AgentDocument } from '@/database/models/agentDocuments';
|
||||
|
||||
import type { SkillFrontmatter } from './frontmatter';
|
||||
|
||||
/**
|
||||
* User-provided selector for a managed skill.
|
||||
*/
|
||||
export interface SkillTargetInput {
|
||||
/** Agent document binding id from `agent_documents.id`; bundle ids and index ids both resolve to the owning bundle. */
|
||||
agentDocumentId?: string;
|
||||
/** Stable skill bundle name. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable reference to a managed skill document.
|
||||
*/
|
||||
export interface SkillDocumentRef {
|
||||
/** Agent document binding id from `agent_documents.id`. */
|
||||
agentDocumentId: string;
|
||||
/** Backing document id from `documents.id`. */
|
||||
documentId: string;
|
||||
/** Backing document filename. */
|
||||
filename: string;
|
||||
/** Backing document title. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List item returned for a managed skill bundle and its index.
|
||||
*/
|
||||
export interface SkillSummary {
|
||||
/** Parent bundle document reference. */
|
||||
bundle: SkillDocumentRef;
|
||||
/** Skill description parsed from index frontmatter. */
|
||||
description: string;
|
||||
/** Child index document reference. */
|
||||
index: SkillDocumentRef;
|
||||
/** Stable skill bundle name. */
|
||||
name: string;
|
||||
/** Human-readable skill title. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full managed skill detail including optional index content.
|
||||
*/
|
||||
export interface SkillDetail extends SkillSummary {
|
||||
/** Raw SKILL.md content when requested by callers. */
|
||||
content?: string;
|
||||
/** Parsed index frontmatter. */
|
||||
frontmatter: SkillFrontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input used to list managed skill bundles for an agent.
|
||||
*/
|
||||
export interface ListSkillsInput {
|
||||
/** Agent id that owns the skill documents. */
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input used to read one managed skill by stable name or binding id.
|
||||
*/
|
||||
export interface GetSkillInput extends SkillTargetInput {
|
||||
/** Agent id that owns the skill documents. */
|
||||
agentId: string;
|
||||
/** Include raw SKILL.md content in the response. */
|
||||
includeContent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input used to create a managed skill bundle and index document.
|
||||
*/
|
||||
export interface CreateSkillInput {
|
||||
/** Agent id that owns the skill documents. */
|
||||
agentId: string;
|
||||
/** Markdown body authored by the skill-management agent; must not include YAML frontmatter. */
|
||||
bodyMarkdown: string;
|
||||
/** Frontmatter description to persist for the skill. */
|
||||
description: string;
|
||||
/** Stable skill bundle name. */
|
||||
name: string;
|
||||
/** Existing hinted document binding id from `agent_documents.id` to convert, when present. */
|
||||
sourceAgentDocumentId?: string;
|
||||
/** Human-readable skill title. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input used to replace the SKILL.md index document of an existing skill.
|
||||
*/
|
||||
export interface ReplaceSkillIndexInput extends SkillTargetInput {
|
||||
/** Agent id that owns the skill documents. */
|
||||
agentId: string;
|
||||
/** Replacement Markdown body authored by the skill-management agent; must not include YAML frontmatter. */
|
||||
bodyMarkdown: string;
|
||||
/** Optional frontmatter description override. */
|
||||
description?: string;
|
||||
/** Optional reason stored by later history/audit tasks. */
|
||||
updateReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input used to rename a managed skill bundle and synchronize its index metadata.
|
||||
*/
|
||||
export interface RenameSkillInput extends SkillTargetInput {
|
||||
/** Agent id that owns the skill documents. */
|
||||
agentId: string;
|
||||
/** New stable bundle name. */
|
||||
newName?: string;
|
||||
/** New human-readable title. */
|
||||
newTitle?: string;
|
||||
/** Optional reason stored by later history/audit tasks. */
|
||||
updateReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent document row shape used by the skill-management service.
|
||||
*/
|
||||
export type SkillAgentDocument = AgentDocument;
|
||||
@@ -6,8 +6,17 @@ import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
|
||||
import { agentDocumentsRuntime } from '../agentDocuments';
|
||||
|
||||
const agentSignalProcedureMocks = vi.hoisted(() => ({
|
||||
emitToolOutcomeSafely: vi.fn(),
|
||||
resolveToolOutcomeScope: vi.fn(() => ({
|
||||
scope: { agentId: 'agent-1', userId: 'user-1' },
|
||||
scopeKey: 'agent:agent-1:user:user-1',
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/agentDocuments');
|
||||
vi.mock('@/database/models/task');
|
||||
vi.mock('@/server/services/agentSignal/procedure', () => agentSignalProcedureMocks);
|
||||
|
||||
describe('agentDocumentsRuntime', () => {
|
||||
it('should have correct identifier', () => {
|
||||
@@ -39,14 +48,20 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
copyDocumentById: ReturnType<typeof vi.fn>;
|
||||
createDocument: ReturnType<typeof vi.fn>;
|
||||
createForTopic: ReturnType<typeof vi.fn>;
|
||||
getDocumentSnapshotById: ReturnType<typeof vi.fn>;
|
||||
renameDocumentById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let pinDocument: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
agentSignalProcedureMocks.emitToolOutcomeSafely.mockClear();
|
||||
agentSignalProcedureMocks.resolveToolOutcomeScope.mockClear();
|
||||
serviceImpl = {
|
||||
copyDocumentById: vi.fn().mockResolvedValue(newDoc),
|
||||
createDocument: vi.fn().mockResolvedValue(newDoc),
|
||||
createForTopic: vi.fn().mockResolvedValue(newDoc),
|
||||
getDocumentSnapshotById: vi.fn().mockResolvedValue(newDoc),
|
||||
renameDocumentById: vi.fn().mockResolvedValue(newDoc),
|
||||
};
|
||||
pinDocument = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -69,6 +84,67 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
expect(pinDocument).toHaveBeenCalledWith('task-1', 'documents-row-id', 'agent');
|
||||
});
|
||||
|
||||
it('emits create outcomes with the agent document binding id', async () => {
|
||||
const runtime = agentDocumentsRuntime.factory(buildContext('task-1'));
|
||||
|
||||
await runtime.createDocument({ content: 'body', title: 'Daily Brief' }, { agentId: 'agent-1' });
|
||||
|
||||
expect(agentSignalProcedureMocks.emitToolOutcomeSafely).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiName: 'createDocument',
|
||||
relatedObjects: [
|
||||
{
|
||||
objectId: 'agent-doc-assoc-id',
|
||||
objectType: 'agent-document',
|
||||
relation: 'created',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits copy outcomes with the agent document binding id', async () => {
|
||||
const runtime = agentDocumentsRuntime.factory(buildContext('task-1'));
|
||||
|
||||
await runtime.copyDocument({ id: 'source-agent-doc-id' }, { agentId: 'agent-1' });
|
||||
|
||||
expect(agentSignalProcedureMocks.emitToolOutcomeSafely).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiName: 'copyDocument',
|
||||
relatedObjects: [
|
||||
{
|
||||
objectId: 'agent-doc-assoc-id',
|
||||
objectType: 'agent-document',
|
||||
relation: 'created',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits update outcomes with the input agent document binding id', async () => {
|
||||
serviceImpl.createDocument.mockClear();
|
||||
const runtime = agentDocumentsRuntime.factory(buildContext('task-1'));
|
||||
|
||||
await runtime.renameDocument(
|
||||
{ id: 'agent-doc-assoc-id', newTitle: 'Renamed' },
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(agentSignalProcedureMocks.emitToolOutcomeSafely).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiName: 'renameDocument',
|
||||
relatedObjects: [
|
||||
{
|
||||
objectId: 'agent-doc-assoc-id',
|
||||
objectType: 'agent-document',
|
||||
relation: 'updated',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips pin when no taskId is provided', async () => {
|
||||
const runtime = agentDocumentsRuntime.factory(buildContext());
|
||||
|
||||
@@ -124,7 +200,7 @@ describe('AgentDocumentsExecutionRuntime.createDocument', () => {
|
||||
updateLoadRule: vi.fn(),
|
||||
});
|
||||
|
||||
it('returns documents.id (not agentDocuments.id) for state.documentId', async () => {
|
||||
it('returns both agentDocuments.id and documents.id in create state', async () => {
|
||||
const stub = makeStub();
|
||||
stub.createDocument.mockResolvedValue({
|
||||
documentId: 'documents-row-id',
|
||||
@@ -140,7 +216,10 @@ describe('AgentDocumentsExecutionRuntime.createDocument', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.state).toEqual({ documentId: 'documents-row-id' });
|
||||
expect(result.state).toEqual({
|
||||
agentDocumentId: 'agent-doc-assoc-id',
|
||||
documentId: 'documents-row-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to run without agentId', async () => {
|
||||
@@ -169,7 +248,10 @@ describe('AgentDocumentsExecutionRuntime.createDocument', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.state).toEqual({ documentId: 'documents-row-id' });
|
||||
expect(result.state).toEqual({
|
||||
agentDocumentId: 'agent-doc-assoc-id',
|
||||
documentId: 'documents-row-id',
|
||||
});
|
||||
expect(stub.createTopicDocument).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
content: 'body',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SkillMaintainerExecutionRuntime } from '@lobechat/builtin-tool-skill-maintainer';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SkillManagementDocumentService } from '@/server/services/skillManagement';
|
||||
|
||||
import { skillManagementRuntime } from '../skillManagement';
|
||||
|
||||
vi.mock('@/server/services/skillManagement');
|
||||
|
||||
describe('skillManagementRuntime', () => {
|
||||
/**
|
||||
* @example
|
||||
* The hidden skill-management runtime declares the builtin identifier used by the registry.
|
||||
*/
|
||||
it('declares the skill maintainer runtime identifier', () => {
|
||||
expect(skillManagementRuntime.identifier).toBe('lobe-skill-maintainer');
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* Server runtime construction requires persistence context.
|
||||
*/
|
||||
it('throws if required server context is missing', () => {
|
||||
expect(() =>
|
||||
skillManagementRuntime.factory({ serverDB: {} as never, toolManifestMap: {} }),
|
||||
).toThrow('userId and serverDB are required for Skill Management execution');
|
||||
expect(() => skillManagementRuntime.factory({ toolManifestMap: {}, userId: 'user-1' })).toThrow(
|
||||
'userId and serverDB are required for Skill Management execution',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @example
|
||||
* The registration factory creates a package-level runtime backed by SkillManagementDocumentService.
|
||||
*/
|
||||
it('constructs a SkillMaintainerExecutionRuntime', () => {
|
||||
const runtime = skillManagementRuntime.factory({
|
||||
serverDB: {} as never,
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(runtime).toBeInstanceOf(SkillMaintainerExecutionRuntime);
|
||||
expect(SkillManagementDocumentService).toHaveBeenCalledWith({}, 'user-1');
|
||||
});
|
||||
});
|
||||
@@ -24,8 +24,8 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
const { taskId } = context;
|
||||
const emitDocumentOutcome = async (input: {
|
||||
agentId?: string;
|
||||
agentDocumentId?: string;
|
||||
apiName: string;
|
||||
documentId?: string;
|
||||
errorReason?: string;
|
||||
relation?: string;
|
||||
status: 'failed' | 'succeeded';
|
||||
@@ -49,8 +49,14 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
messageId: context.messageId,
|
||||
operationId: context.operationId,
|
||||
policyStateStore: redisPolicyStateStore,
|
||||
relatedObjects: input.documentId
|
||||
? [{ objectId: input.documentId, objectType: 'document', relation: input.relation }]
|
||||
relatedObjects: input.agentDocumentId
|
||||
? [
|
||||
{
|
||||
objectId: input.agentDocumentId,
|
||||
objectType: 'agent-document',
|
||||
relation: input.relation,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
scope,
|
||||
scopeKey,
|
||||
@@ -65,7 +71,7 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
const withDocumentOutcome = async <T>(
|
||||
input: {
|
||||
agentId?: string;
|
||||
getDocumentId?: (result: T) => string | undefined;
|
||||
getAgentDocumentId?: (result: T) => string | undefined;
|
||||
apiName: string;
|
||||
relation: string;
|
||||
summary: string;
|
||||
@@ -77,8 +83,8 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
const result = await operation();
|
||||
await emitDocumentOutcome({
|
||||
agentId: input.agentId,
|
||||
agentDocumentId: input.getAgentDocumentId?.(result),
|
||||
apiName: input.apiName,
|
||||
documentId: input.getDocumentId?.(result),
|
||||
relation: input.relation,
|
||||
status: 'succeeded',
|
||||
summary: input.summary,
|
||||
@@ -113,7 +119,7 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
{
|
||||
agentId,
|
||||
apiName: 'copyDocument',
|
||||
getDocumentId: (result) => result?.documentId,
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents copied a document.',
|
||||
toolAction: 'copy',
|
||||
@@ -121,32 +127,32 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
() => service.copyDocumentById(id, newTitle, agentId),
|
||||
),
|
||||
),
|
||||
createDocument: async ({ agentId, content, title }) =>
|
||||
createDocument: async ({ agentId, content, hintIsSkill, title }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'createDocument',
|
||||
getDocumentId: (result) => result?.documentId,
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents created a document.',
|
||||
toolAction: 'create',
|
||||
},
|
||||
() => service.createDocument(agentId, title, content),
|
||||
() => service.createDocument(agentId, title, content, { hintIsSkill }),
|
||||
),
|
||||
),
|
||||
createTopicDocument: async ({ agentId, content, title, topicId }) =>
|
||||
createTopicDocument: async ({ agentId, content, hintIsSkill, title, topicId }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'createTopicDocument',
|
||||
getDocumentId: (result) => result?.documentId,
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents created a topic document.',
|
||||
toolAction: 'create',
|
||||
},
|
||||
() => service.createForTopic(agentId, title, content, topicId),
|
||||
() => service.createForTopic(agentId, title, content, topicId, { hintIsSkill }),
|
||||
),
|
||||
),
|
||||
listDocuments: async ({ agentId }) => {
|
||||
@@ -172,7 +178,7 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
{
|
||||
agentId,
|
||||
apiName: 'modifyNodes',
|
||||
getDocumentId: () => id,
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents modified document nodes.',
|
||||
toolAction: 'edit',
|
||||
@@ -185,7 +191,7 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
{
|
||||
agentId,
|
||||
apiName: 'removeDocument',
|
||||
getDocumentId: () => id,
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'removed',
|
||||
summary: 'Agent documents removed a document.',
|
||||
toolAction: 'remove',
|
||||
@@ -197,7 +203,7 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
{
|
||||
agentId,
|
||||
apiName: 'renameDocument',
|
||||
getDocumentId: () => id,
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents renamed a document.',
|
||||
toolAction: 'rename',
|
||||
@@ -209,7 +215,7 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
{
|
||||
agentId,
|
||||
apiName: 'replaceDocumentContent',
|
||||
getDocumentId: () => id,
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents replaced document content.',
|
||||
toolAction: 'replace',
|
||||
@@ -221,7 +227,7 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
{
|
||||
agentId,
|
||||
apiName: 'updateLoadRule',
|
||||
getDocumentId: () => id,
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents updated a load rule.',
|
||||
toolAction: 'update',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { memoryRuntime } from './memory';
|
||||
import { messageRuntime } from './message';
|
||||
import { notebookRuntime } from './notebook';
|
||||
import { remoteDeviceRuntime } from './remoteDevice';
|
||||
import { skillManagementRuntime } from './skillManagement';
|
||||
import { skillsRuntime } from './skills';
|
||||
import { skillStoreRuntime } from './skillStore';
|
||||
import { taskRuntime } from './task';
|
||||
@@ -51,6 +52,7 @@ registerRuntimes([
|
||||
cloudSandboxRuntime,
|
||||
calculatorRuntime,
|
||||
agentDocumentsRuntime,
|
||||
skillManagementRuntime,
|
||||
notebookRuntime,
|
||||
skillStoreRuntime,
|
||||
skillsRuntime,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
type CreateSkillArgs,
|
||||
type GetSkillArgs,
|
||||
type ListSkillsArgs,
|
||||
type RenameSkillArgs,
|
||||
type ReplaceSkillIndexArgs,
|
||||
SkillMaintainerExecutionRuntime,
|
||||
SkillMaintainerIdentifier,
|
||||
type SkillMaintainerRuntimeService,
|
||||
} from '@lobechat/builtin-tool-skill-maintainer';
|
||||
|
||||
import { SkillManagementDocumentService } from '@/server/services/skillManagement';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
/**
|
||||
* Creates the server runtime for hidden skill-management builtin tools.
|
||||
*
|
||||
* Use when:
|
||||
* - Tool execution needs to bind the package-level runtime to server persistence.
|
||||
* - Agent Signal workers need document-backed managed skill operations.
|
||||
*
|
||||
* Expects:
|
||||
* - `userId` and `serverDB` exist in tool execution context.
|
||||
* - Per-call runtime context supplies the target `agentId`.
|
||||
*
|
||||
* Returns:
|
||||
* - A runtime that delegates bundle/index invariants to {@link SkillManagementDocumentService}.
|
||||
*/
|
||||
export const skillManagementRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
throw new Error('userId and serverDB are required for Skill Management execution');
|
||||
}
|
||||
|
||||
const service = new SkillManagementDocumentService(context.serverDB, context.userId);
|
||||
|
||||
const runtimeService: SkillMaintainerRuntimeService = {
|
||||
createSkill: (params: CreateSkillArgs & { agentId: string }) => service.createSkill(params),
|
||||
getSkill: (params: GetSkillArgs & { agentId: string }) => service.getSkill(params),
|
||||
listSkills: (params: ListSkillsArgs & { agentId: string }) => service.listSkills(params),
|
||||
renameSkill: (params: RenameSkillArgs & { agentId: string }) =>
|
||||
service.renameSkill({
|
||||
agentDocumentId: params.agentDocumentId,
|
||||
agentId: params.agentId,
|
||||
name: params.name,
|
||||
newName: params.newName,
|
||||
newTitle: params.newTitle,
|
||||
updateReason: params.reason,
|
||||
}),
|
||||
replaceSkillIndex: (params: ReplaceSkillIndexArgs & { agentId: string }) =>
|
||||
service.replaceSkillIndex({
|
||||
agentDocumentId: params.agentDocumentId,
|
||||
agentId: params.agentId,
|
||||
bodyMarkdown: params.bodyMarkdown,
|
||||
description: params.description,
|
||||
name: params.name,
|
||||
updateReason: params.reason,
|
||||
}),
|
||||
};
|
||||
|
||||
return new SkillMaintainerExecutionRuntime(runtimeService);
|
||||
},
|
||||
identifier: SkillMaintainerIdentifier,
|
||||
};
|
||||
@@ -75,7 +75,12 @@ class AgentDocumentService {
|
||||
return result;
|
||||
};
|
||||
|
||||
createDocument = async (params: { agentId: string; content: string; title: string }) => {
|
||||
createDocument = async (params: {
|
||||
agentId: string;
|
||||
content: string;
|
||||
hintIsSkill?: boolean;
|
||||
title: string;
|
||||
}) => {
|
||||
const result = await lambdaClient.agentDocument.createDocument.mutate(params);
|
||||
await invalidateDocumentMutation({
|
||||
agentDocumentId: getAgentDocumentId(result),
|
||||
@@ -90,6 +95,7 @@ class AgentDocumentService {
|
||||
createForTopic = async (params: {
|
||||
agentId: string;
|
||||
content: string;
|
||||
hintIsSkill?: boolean;
|
||||
title: string;
|
||||
topicId: string;
|
||||
}) => {
|
||||
|
||||
@@ -7,10 +7,10 @@ import { agentDocumentService } from '@/services/agentDocument';
|
||||
const runtime = new AgentDocumentsExecutionRuntime({
|
||||
copyDocument: ({ agentId, id, newTitle }) =>
|
||||
agentDocumentService.copyDocument({ agentId, id, newTitle }),
|
||||
createDocument: ({ agentId, content, title }) =>
|
||||
agentDocumentService.createDocument({ agentId, content, title }),
|
||||
createTopicDocument: ({ agentId, content, title, topicId }) =>
|
||||
agentDocumentService.createForTopic({ agentId, content, title, topicId }),
|
||||
createDocument: ({ agentId, content, hintIsSkill, title }) =>
|
||||
agentDocumentService.createDocument({ agentId, content, hintIsSkill, title }),
|
||||
createTopicDocument: ({ agentId, content, hintIsSkill, title, topicId }) =>
|
||||
agentDocumentService.createForTopic({ agentId, content, hintIsSkill, title, topicId }),
|
||||
listDocuments: async ({ agentId }) => {
|
||||
const docs = await agentDocumentService.listDocuments({ agentId });
|
||||
return docs.map((d) => ({
|
||||
|
||||
Reference in New Issue
Block a user