feat(skills): recognize project-level skills in the homogeneous agent runtime (#15110)

This commit is contained in:
Arvin Xu
2026-05-22 19:22:41 +08:00
committed by GitHub
parent a35877f676
commit a0fac0b700
35 changed files with 1221 additions and 179 deletions
+3 -3
View File
@@ -962,7 +962,7 @@
"workingPanel.resources.deleteError": "Failed to delete document",
"workingPanel.resources.deleteSuccess": "Document deleted",
"workingPanel.resources.deleteTitle": "Delete document?",
"workingPanel.resources.empty": "No documents yet. Documents associated with this agent will show up here.",
"workingPanel.resources.empty": "No webpages. Webpages crawled in this agent will show up here.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "Documents",
"workingPanel.resources.filter.skills": "Skills",
@@ -1019,10 +1019,10 @@
"workingPanel.review.viewMode.unified": "Switch to unified view",
"workingPanel.review.wordWrap.disable": "Disable word wrap",
"workingPanel.review.wordWrap.enable": "Enable word wrap",
"workingPanel.skills.empty": "No skills found in this project",
"workingPanel.skills.emptyAgent": "No skills attached to this agent",
"workingPanel.skills.empty": "No skills available",
"workingPanel.skills.section.agent": "Agent skills",
"workingPanel.skills.section.project": "Project skills",
"workingPanel.skills.section.user": "User skills",
"workingPanel.skills.title": "Skills",
"workingPanel.space": "Space",
"workingPanel.title": "Working Panel",
+2
View File
@@ -264,6 +264,8 @@
"builtins.lobe-skill-store.render.repository": "Repository",
"builtins.lobe-skill-store.render.version": "Version",
"builtins.lobe-skill-store.title": "Skill Store",
"builtins.lobe-skills.apiName.activateAgentSkill": "Activate Agent Skill",
"builtins.lobe-skills.apiName.activateProjectSkill": "Activate Project Skill",
"builtins.lobe-skills.apiName.activateSkill": "Activate Skill",
"builtins.lobe-skills.apiName.execScript": "Run Script",
"builtins.lobe-skills.apiName.exportFile": "Export File",
+3 -3
View File
@@ -962,7 +962,7 @@
"workingPanel.resources.deleteError": "删除失败",
"workingPanel.resources.deleteSuccess": "文档已删除",
"workingPanel.resources.deleteTitle": "删除该文档?",
"workingPanel.resources.empty": "暂无文档。与此智能体关联的文档将显示在这里。",
"workingPanel.resources.empty": "暂无网页。本智能体抓取的网页将显示在这里。",
"workingPanel.resources.error": "资源加载失败",
"workingPanel.resources.filter.documents": "文档",
"workingPanel.resources.filter.skills": "技能",
@@ -1019,10 +1019,10 @@
"workingPanel.review.viewMode.unified": "切换到合并视图",
"workingPanel.review.wordWrap.disable": "关闭自动换行",
"workingPanel.review.wordWrap.enable": "启用自动换行",
"workingPanel.skills.empty": "当前项目未发现技能",
"workingPanel.skills.emptyAgent": "该智能体未附加技能",
"workingPanel.skills.empty": "暂无可用技能",
"workingPanel.skills.section.agent": "智能体技能",
"workingPanel.skills.section.project": "项目技能",
"workingPanel.skills.section.user": "用户技能",
"workingPanel.skills.title": "技能",
"workingPanel.space": "空间",
"workingPanel.title": "工作面板",
+2
View File
@@ -264,6 +264,8 @@
"builtins.lobe-skill-store.render.repository": "存储库",
"builtins.lobe-skill-store.render.version": "版本",
"builtins.lobe-skill-store.title": "技能商店",
"builtins.lobe-skills.apiName.activateAgentSkill": "激活 Agent Skill",
"builtins.lobe-skills.apiName.activateProjectSkill": "激活 Project Skill",
"builtins.lobe-skills.apiName.activateSkill": "激活技能",
"builtins.lobe-skills.apiName.execScript": "执行脚本",
"builtins.lobe-skills.apiName.exportFile": "导出文件",
@@ -3,12 +3,31 @@
import { type BuiltinInspectorProps } from '@lobechat/types';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
import { type TFunction } from 'i18next';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
/**
* `t` is invoked with literal keys per branch so i18next's typed-key map can
* still validate the call site.
*/
const resolveLabel = (t: TFunction<'plugin'>, source: ActivateSkillSource | undefined): string => {
switch (source) {
case 'agent': {
return t('builtins.lobe-skills.apiName.activateAgentSkill');
}
case 'project': {
return t('builtins.lobe-skills.apiName.activateProjectSkill');
}
default: {
return t('builtins.lobe-skills.apiName.activateSkill');
}
}
};
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
@@ -50,19 +69,20 @@ export const ActivateSkillInspector = memo<
const { t } = useTranslation('plugin');
const name = args?.name || partialArgs?.name;
const displayName = pluginState?.name || name;
const displayName = pluginState?.title || pluginState?.name || name;
const label = resolveLabel(t, pluginState?.source);
if (isArgumentsStreaming) {
if (!displayName)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.activateSkill')}</span>
<span>{label}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
<span>{label}:</span>
<span className={styles.chip}>
<SkillsIcon className={styles.skillIcon} size={12} />
<span className={styles.skillName}>{displayName}</span>
@@ -73,7 +93,7 @@ export const ActivateSkillInspector = memo<
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
<span>{label}:</span>
{displayName && (
<span className={styles.chip}>
<SkillsIcon className={styles.skillIcon} size={12} />
@@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const ActivateSkill = memo<BuiltinRenderProps<ActivateSkillParams, ActivateSkillState>>(
({ content, pluginState }) => {
const { description, name } = pluginState || {};
const { description, name, title } = pluginState || {};
const displayName = title || name;
if (!name) return null;
if (!displayName) return null;
return (
<Flexbox className={styles.container}>
<Flexbox className={styles.header} gap={4}>
<span className={styles.name}>{name}</span>
<span className={styles.name}>{displayName}</span>
{description && <span className={styles.description}>{description}</span>}
</Flexbox>
{content && (
@@ -3,6 +3,7 @@ export { systemPrompt } from './systemRole';
export {
type ActivatedToolInfo,
type ActivateSkillParams,
type ActivateSkillSource,
type ActivateSkillState,
type ActivateToolsParams,
type ActivateToolsState,
@@ -27,9 +27,15 @@ export interface ActivateSkillParams {
name: string;
}
export type ActivateSkillSource = 'agent' | 'builtin' | 'project' | 'user';
export interface ActivateSkillState {
description?: string;
hasResources: boolean;
id: string;
name: string;
/** Skill origin — drives the inspector label (e.g. "Activate Agent Skill"). */
source?: ActivateSkillSource;
/** Friendly title for UI display; falls back to `name` when unset. */
title?: string;
}
@@ -182,4 +182,165 @@ describe('SkillsExecutionRuntime', () => {
});
});
});
describe('project skills', () => {
const projectSkill = {
location: '/repo/.agents/skills/deploy/SKILL.md',
name: 'deploy',
};
it('activateSkill reads SKILL.md and appends a directory hint for lazy discovery', async () => {
const readFile = vi.fn().mockResolvedValue('# Deploy\nRun the deploy steps.');
const listFiles = vi.fn().mockResolvedValue([]);
const runtime = new SkillsExecutionRuntime({
deviceFileAccess: { listFiles, readFile },
projectSkills: [projectSkill],
service: createMockService(),
});
const result = await runtime.activateSkill({ name: 'deploy' });
expect(readFile).toHaveBeenCalledWith('/repo/.agents/skills/deploy/SKILL.md');
expect(result.success).toBe(true);
expect(result.content).toContain('Run the deploy steps.');
// The hint points at the skill's directory and instructs the model to
// call `local-system.listFiles` itself rather than pre-enumerating here.
expect(result.content).toContain('/repo/.agents/skills/deploy');
expect(result.content).toContain('listFiles');
expect(result.state).toMatchObject({ name: 'deploy', source: 'project' });
});
it('activateSkill takes precedence over a same-named DB skill', async () => {
const readFile = vi.fn().mockResolvedValue('project content');
const listFiles = vi.fn().mockResolvedValue([]);
const findByName = vi
.fn()
.mockResolvedValue({ content: 'db content', id: 'x', name: 'deploy' });
const runtime = new SkillsExecutionRuntime({
deviceFileAccess: { listFiles, readFile },
projectSkills: [projectSkill],
service: createMockService({ findByName }),
});
const result = await runtime.activateSkill({ name: 'deploy' });
expect(result.content).toContain('project content');
expect(findByName).not.toHaveBeenCalled();
});
it('activateSkill fails clearly when no device file access is available', async () => {
const runtime = new SkillsExecutionRuntime({
projectSkills: [projectSkill],
service: createMockService(),
});
const result = await runtime.activateSkill({ name: 'deploy' });
expect(result.success).toBe(false);
expect(result.content).toContain('no device file access');
});
it('readReference resolves a project file relative to the SKILL.md directory', async () => {
const readFile = vi.fn().mockResolvedValue('print("run")');
const listFiles = vi.fn().mockResolvedValue(['SKILL.md', 'scripts/run.py']);
const runtime = new SkillsExecutionRuntime({
deviceFileAccess: { listFiles, readFile },
projectSkills: [projectSkill],
service: createMockService(),
});
const result = await runtime.readReference({ id: 'deploy', path: 'scripts/run.py' });
expect(listFiles).toHaveBeenCalledWith('/repo/.agents/skills/deploy');
expect(readFile).toHaveBeenCalledWith('/repo/.agents/skills/deploy/scripts/run.py');
expect(result.success).toBe(true);
expect(result.content).toBe('print("run")');
});
it('readReference rejects paths not in the declared skill file list', async () => {
const readFile = vi.fn();
const listFiles = vi.fn().mockResolvedValue(['SKILL.md', 'scripts/run.py']);
const runtime = new SkillsExecutionRuntime({
deviceFileAccess: { listFiles, readFile },
projectSkills: [projectSkill],
service: createMockService(),
});
const result = await runtime.readReference({ id: 'deploy', path: 'secrets.json' });
expect(listFiles).toHaveBeenCalledWith('/repo/.agents/skills/deploy');
expect(readFile).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.content).toContain('Resource not found in project skill');
});
it('readReference rejects hidden segments before consulting the device', async () => {
const readFile = vi.fn();
const listFiles = vi.fn();
const runtime = new SkillsExecutionRuntime({
deviceFileAccess: { listFiles, readFile },
projectSkills: [projectSkill],
service: createMockService(),
});
const result = await runtime.readReference({ id: 'deploy', path: '.env' });
expect(listFiles).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.content).toContain('not a permitted skill resource');
});
});
describe('activation precedence', () => {
it('DB skill wins over a same-named builtin (matches injection dedupe)', async () => {
const findByName = vi.fn().mockResolvedValue({
content: 'user-authored content',
description: 'user skill',
id: 'user-1',
name: 'overlap',
});
const runtime = new SkillsExecutionRuntime({
builtinSkills: [
{
content: 'builtin content',
description: 'builtin skill',
identifier: 'overlap',
name: 'overlap',
source: 'builtin',
},
],
service: createMockService({ findByName }),
});
const result = await runtime.activateSkill({ name: 'overlap' });
expect(findByName).toHaveBeenCalledWith('overlap');
expect(result.success).toBe(true);
expect(result.content).toContain('user-authored content');
expect(result.state).toMatchObject({ name: 'overlap', source: 'user' });
});
it('falls through to builtin when no DB skill exists', async () => {
const findByName = vi.fn().mockResolvedValue(undefined);
const runtime = new SkillsExecutionRuntime({
builtinSkills: [
{
content: 'builtin only',
description: 'builtin skill',
identifier: 'artifacts',
name: 'artifacts',
source: 'builtin',
},
],
service: createMockService({ findByName }),
});
const result = await runtime.activateSkill({ name: 'artifacts' });
expect(result.success).toBe(true);
expect(result.content).toContain('builtin only');
expect(result.state).toMatchObject({ name: 'artifacts', source: 'builtin' });
});
});
});
@@ -1,3 +1,4 @@
import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
import { formatCommandResult, resourcesTreePrompt } from '@lobechat/prompts';
import type {
BuiltinServerRuntimeOutput,
@@ -52,18 +53,96 @@ export interface SkillRuntimeService {
runCommand?: (options: RunCommandOptions) => Promise<CommandResult>;
}
/**
* A project-level skill discovered on the device filesystem
* (`.agents/skills` / `.claude/skills`). The runtime only needs the name to
* match an `activateSkill`/`readReference` call and the absolute SKILL.md path
* to read its content. The directory tree is enumerated lazily on activation
* via `DeviceFileAccess.listFiles` — keeping it out of the op param payload.
*/
export interface ProjectSkillRuntimeItem {
/** Absolute path to the skill's SKILL.md on the device. */
location: string;
name: string;
}
/**
* Device filesystem access used to load project skills. The server wires this
* to the `local-system` tool over the device gateway, so the runtime stays
* transport-agnostic — it just needs to read/enumerate files on the device.
*/
export interface DeviceFileAccess {
/**
* Recursively enumerate files under `dir`, returning POSIX-style paths
* relative to `dir`. `readReference` validates user-supplied paths against
* this list so the model can only read files the project skill actually
* exposes (no hidden files, no escape outside the skill directory).
*/
listFiles: (dir: string) => Promise<string[]>;
/** Read a text file's content from the device. */
readFile: (path: string) => Promise<string>;
}
export interface SkillsExecutionRuntimeOptions {
builtinSkills?: BuiltinSkill[];
/** Reads project skill files from the device (local-system over the gateway). */
deviceFileAccess?: DeviceFileAccess;
/** Project skills discovered on the device filesystem. */
projectSkills?: ProjectSkillRuntimeItem[];
service: SkillRuntimeService;
}
/** Cross-platform dirname for absolute paths (POSIX or Windows separators). */
const getDirname = (filePath: string): string => {
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
return idx === -1 ? '' : filePath.slice(0, idx);
};
/** Join a directory with a relative path, preserving the directory's separator. */
const joinPath = (dir: string, rel: string): string => {
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
const trimmed = dir.endsWith(sep) ? dir.slice(0, -sep.length) : dir;
return `${trimmed}${sep}${rel}`;
};
/**
* Normalize a user-supplied relative path to POSIX form: backslashes → `/`,
* trim leading `./` and slashes. Used to compare requested paths against the
* skill's `listFiles` result (which is canonicalized the same way by the
* device-side enumerator) and to detect hidden segments.
*/
const normalizeRelativePath = (rel: string): string =>
rel
.replaceAll('\\', '/')
.replace(/^(?:\.\/)+/, '')
.replace(/^\/+/, '');
/** True when any segment in `rel` is hidden (starts with `.`) — `.env`, `.git/...`. */
const hasHiddenSegment = (rel: string): boolean =>
rel.split('/').some((seg) => seg.startsWith('.'));
/**
* Hint appended to activated project-skill content so the model knows how to
* discover the rest of the skill's directory. We deliberately don't enumerate
* the tree here — the model has `local-system.listFiles` available and can
* call it on demand, which keeps the op-param payload small.
*/
const buildProjectDirectoryHint = (skillName: string, skillDir: string): string =>
`## Skill resources
This project skill lives in \`${skillDir}\`. Use \`local-system.listFiles\` on that path to discover reference files, then \`readReference\` with skillName="${skillName}" + the relative path to load any of them.`;
export class SkillsExecutionRuntime {
private builtinSkills: BuiltinSkill[];
private projectSkills: ProjectSkillRuntimeItem[];
private deviceFileAccess?: DeviceFileAccess;
private service: SkillRuntimeService;
constructor(options: SkillsExecutionRuntimeOptions) {
this.service = options.service;
this.builtinSkills = options.builtinSkills || [];
this.projectSkills = options.projectSkills || [];
this.deviceFileAccess = options.deviceFileAccess;
}
async execScript(args: ExecScriptParams): Promise<BuiltinServerRuntimeOutput> {
@@ -168,16 +247,87 @@ export class SkillsExecutionRuntime {
async readReference(args: ReadReferenceParams): Promise<BuiltinServerRuntimeOutput> {
const { id, path } = args;
// Validate path to prevent traversal attacks
if (path.includes('..')) {
return {
content: 'Invalid path: path traversal is not allowed',
success: false,
};
}
try {
// Check builtin skills first
// Project skills resolve references relative to the SKILL.md directory,
// read through the device file access (local-system over the gateway).
const projectSkill = this.projectSkills.find((s) => s.name === id);
if (projectSkill) {
if (!this.deviceFileAccess) {
return {
content: `Project skill "${id}" cannot be read: no device file access available.`,
success: false,
};
}
// Normalize and reject obviously-unsafe shapes up front. The
// `listFiles` membership check below is the real authority, but
// failing fast here keeps the error message specific.
const normalized = normalizeRelativePath(path);
if (!normalized || normalized.includes('..') || hasHiddenSegment(normalized)) {
return {
content: `Invalid path: "${path}" is not a permitted skill resource`,
success: false,
};
}
// Enumerate the skill directory and only allow files the device
// surface advertises. Without this, a model could request any path
// under the skill dir (e.g. `.env`, `node_modules/…`) that was never
// declared as a skill resource. The device-side enumerator already
// filters hidden files; we re-check here as defense in depth.
const skillDir = getDirname(projectSkill.location);
const allowed = new Set(
(await this.deviceFileAccess.listFiles(skillDir)).map((f) => normalizeRelativePath(f)),
);
if (!allowed.has(normalized)) {
return {
content: `Resource not found in project skill "${id}": "${path}"`,
success: false,
};
}
const fullPath = joinPath(skillDir, normalized);
const content = await this.deviceFileAccess.readFile(fullPath);
return {
content,
state: { encoding: 'utf8', fileType: 'text/plain', fullPath, path: normalized },
success: true,
};
}
// For non-project skills, keep the traversal guard. Builtin / user
// skills look paths up via an explicit `resources` map or service, so
// the `..` substring is the only realistic traversal vector.
if (path.includes('..')) {
return {
content: 'Invalid path: path traversal is not allowed',
success: false,
};
}
// DB (user-level) skills win over builtins on name collision — matches
// the `<available_skills>` dedupe precedence (project > user > agent >
// builtin) in `aiAgent/index.ts`. Without this, the model would see a
// user skill in the list but `readReference` would silently read the
// shadowed builtin's resources.
const skill = await this.service.findByName(id);
if (skill) {
const resource = await this.service.readResource(skill.id, path);
return {
content: resource.content,
state: {
encoding: resource.encoding,
fileType: resource.fileType,
fullPath: resource.fullPath,
path: resource.path,
size: resource.size,
},
success: true,
};
}
// Fall back to builtin skills (includes agent-document skill bundles
// via the `agent-skills:` identifier prefix).
const builtinSkill = this.builtinSkills.find((s) => s.name === id);
if (builtinSkill?.resources) {
const meta = builtinSkill.resources[path];
@@ -199,26 +349,9 @@ export class SkillsExecutionRuntime {
};
}
// Resolve id: try findByName for DB skills
const skill = await this.service.findByName(id);
if (!skill) {
return {
content: `Skill not found: "${id}"`,
success: false,
};
}
const resource = await this.service.readResource(skill.id, path);
return {
content: resource.content,
state: {
encoding: resource.encoding,
fileType: resource.fileType,
fullPath: resource.fullPath,
path: resource.path,
size: resource.size,
},
success: true,
content: `Skill not found: "${id}"`,
success: false,
};
} catch (e) {
return {
@@ -231,7 +364,75 @@ export class SkillsExecutionRuntime {
async activateSkill(args: ActivateSkillParams): Promise<BuiltinServerRuntimeOutput> {
const { name } = args;
// Check builtin skills first — no DB query needed
// Project skills (filesystem SKILL.md) take precedence over db/builtin.
const projectSkill = this.projectSkills.find((s) => s.name === name);
if (projectSkill) {
if (!this.deviceFileAccess) {
return {
content: `Project skill "${name}" cannot be loaded: no device file access available.`,
success: false,
};
}
try {
let content = await this.deviceFileAccess.readFile(projectSkill.location);
// Don't enumerate the directory here — let the model do it on demand
// via `local-system.listFiles`. Just point at the skill's directory so
// it knows where to look. Keeps the op-param payload small and avoids
// a second deviceProxy round-trip at activation time.
const skillDir = getDirname(projectSkill.location);
if (skillDir) {
content += '\n\n' + buildProjectDirectoryHint(name, skillDir);
}
return {
content,
state: {
hasResources: false,
location: projectSkill.location,
name,
source: 'project',
},
success: true,
};
} catch (e) {
return {
content: `Failed to load project skill "${name}": ${(e as Error).message}`,
success: false,
};
}
}
// DB (user-level) skills win over builtins on name collision — matches
// the `<available_skills>` dedupe precedence (project > user > agent >
// builtin) in `aiAgent/index.ts`. Without this, the model would see a
// user skill in the list but `activateSkill` would silently load the
// shadowed builtin instead.
const skill = await this.service.findByName(name);
if (skill) {
const hasResources = !!(skill.resources && Object.keys(skill.resources).length > 0);
let content = skill.content || '';
if (hasResources && skill.resources) {
content += '\n\n' + resourcesTreePrompt(skill.name, skill.resources);
}
return {
content,
state: {
description: skill.description || undefined,
hasResources,
id: skill.id,
name: skill.name,
source: 'user',
},
success: true,
};
}
// Fall back to builtin skills (includes agent-document skill bundles via
// the `agent-skills:` identifier prefix).
const builtinSkill = this.builtinSkills.find((s) => s.name === name);
if (builtinSkill) {
let content = builtinSkill.content;
@@ -243,6 +444,12 @@ export class SkillsExecutionRuntime {
content += '\n\n' + resourcesTreePrompt(builtinSkill.name, builtinSkill.resources);
}
// Agent-document skill bundles flow through the builtin path with the
// `agent-skills:` prefix on their identifier. Tag the result so the
// inspector can pick the right label ("Activate Agent Skill") and prefer
// the friendly `title` over the raw `agent-skills:<filename>` name.
const isAgentSkill = builtinSkill.identifier.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX);
return {
content,
state: {
@@ -250,43 +457,22 @@ export class SkillsExecutionRuntime {
hasResources,
identifier: builtinSkill.identifier,
name: builtinSkill.name,
source: isAgentSkill ? 'agent' : 'builtin',
...(builtinSkill.title && { title: builtinSkill.title }),
},
success: true,
};
}
// Fall back to DB query for user/market skills
const skill = await this.service.findByName(name);
if (!skill) {
const { data: allSkills } = await this.service.findAll();
const availableSkills = allSkills.map((s) => ({
description: s.description,
name: s.name,
}));
return {
content: `Skill not found: "${name}". Available skills: ${JSON.stringify(availableSkills)}`,
success: false,
};
}
const hasResources = !!(skill.resources && Object.keys(skill.resources).length > 0);
let content = skill.content || '';
if (hasResources && skill.resources) {
content += '\n\n' + resourcesTreePrompt(skill.name, skill.resources);
}
const { data: allSkills } = await this.service.findAll();
const availableSkills = allSkills.map((s) => ({
description: s.description,
name: s.name,
}));
return {
content,
state: {
description: skill.description || undefined,
hasResources,
id: skill.id,
name: skill.name,
},
success: true,
content: `Skill not found: "${name}". Available skills: ${JSON.stringify(availableSkills)}`,
success: false,
};
}
@@ -1,14 +1,47 @@
'use client';
import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
import { type BuiltinInspectorProps } from '@lobechat/types';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
import { type TFunction } from 'i18next';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
/**
* Resolve the inspector label. State-side `source` is the authority once the
* tool result has streamed in; while args are still streaming we only have the
* raw `name` to go on, so detect agent skills via the identifier prefix as a
* best-effort fallback. Project skills can't be inferred from the bare name
* (no prefix), so they show "Activate Skill" until the result lands.
*
* `t` is invoked with literal keys per branch so i18next's typed-key map can
* still validate the call site.
*/
const resolveLabel = (
t: TFunction<'plugin'>,
source: ActivateSkillSource | undefined,
rawName: string | undefined,
): string => {
const effective: ActivateSkillSource =
source ?? (rawName?.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX) ? 'agent' : 'builtin');
switch (effective) {
case 'agent': {
return t('builtins.lobe-skills.apiName.activateAgentSkill');
}
case 'project': {
return t('builtins.lobe-skills.apiName.activateProjectSkill');
}
default: {
return t('builtins.lobe-skills.apiName.activateSkill');
}
}
};
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
@@ -50,19 +83,20 @@ export const RunSkillInspector = memo<
const { t } = useTranslation('plugin');
const name = args?.name || partialArgs?.name;
const displayName = pluginState?.name || name;
const displayName = pluginState?.title || pluginState?.name || name;
const label = resolveLabel(t, pluginState?.source, name);
if (isArgumentsStreaming) {
if (!displayName)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.activateSkill')}</span>
<span>{label}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
<span>{label}:</span>
<span className={styles.chip}>
<SkillsIcon className={styles.skillIcon} size={12} />
<span className={styles.skillName}>{displayName}</span>
@@ -73,7 +107,7 @@ export const RunSkillInspector = memo<
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
<span>{label}:</span>
{displayName && (
<span className={styles.chip}>
<SkillsIcon className={styles.skillIcon} size={12} />
@@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const RunSkill = memo<BuiltinRenderProps<ActivateSkillParams, ActivateSkillState>>(
({ content, pluginState }) => {
const { description, name } = pluginState || {};
const { description, name, title } = pluginState || {};
const displayName = title || name;
if (!name) return null;
if (!displayName) return null;
return (
<Flexbox className={styles.container}>
<Flexbox className={styles.header} gap={4}>
<span className={styles.name}>{name}</span>
<span className={styles.name}>{displayName}</span>
{description && <span className={styles.description}>{description}</span>}
</Flexbox>
{content && (
@@ -2,6 +2,8 @@ export { SkillsManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
type ActivateSkillParams,
type ActivateSkillSource,
type ActivateSkillState,
type CommandResult,
type ExecScriptActivatedSkill,
type ExecScriptParams,
@@ -12,11 +12,17 @@ export interface ActivateSkillParams {
name: string;
}
export type ActivateSkillSource = 'agent' | 'builtin' | 'project' | 'user';
export interface ActivateSkillState {
description?: string;
hasResources: boolean;
id: string;
name: string;
/** Skill origin — drives the inspector label (e.g. "Activate Agent Skill"). */
source?: ActivateSkillSource;
/** Friendly title for UI display; falls back to `name` when unset. */
title?: string;
}
/**
@@ -1,4 +1,4 @@
import { type SkillItem, skillsPrompts } from '@lobechat/prompts';
import { type SkillItem, type SkillSource, skillsPrompts } from '@lobechat/prompts';
import debug from 'debug';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
@@ -34,6 +34,11 @@ export interface SkillMeta {
identifier: string;
location?: string;
name: string;
/**
* Skill origin. `project` skills are discovered on the device filesystem and
* loaded on demand via the readFile tool (see `location`).
*/
source?: SkillSource;
}
/**
@@ -88,6 +93,7 @@ export class SkillContextProvider extends BaseSystemRoleProvider {
identifier: skill.identifier,
location: skill.location,
name: skill.name,
source: skill.source,
}));
const availableSkillsContent = skillsPrompts(skills);
+20 -5
View File
@@ -1,25 +1,40 @@
export { buildResourcesTreeText, resourcesTreePrompt } from './resourcesTree';
export type SkillSource = 'builtin' | 'project' | 'user';
export interface SkillItem {
description: string;
identifier: string;
location?: string;
name: string;
/**
* Where the skill comes from. `project` skills live on the device filesystem
* (e.g. `.agents/skills/<name>/SKILL.md`) and `location` carries their absolute
* path so the model can load them via the readFile tool.
*/
source?: SkillSource;
}
export const skillPrompt = (skill: SkillItem) =>
skill.location
? ` <skill name="${skill.name}" location="${skill.location}">${skill.description}</skill>`
: ` <skill name="${skill.name}">${skill.description}</skill>`;
export const skillPrompt = (skill: SkillItem) => {
const attrs = [`name="${skill.name}"`];
if (skill.source) attrs.push(`source="${skill.source}"`);
if (skill.location) attrs.push(`location="${skill.location}"`);
return ` <skill ${attrs.join(' ')}>${skill.description}</skill>`;
};
export const skillsPrompts = (skills: SkillItem[]) => {
if (skills.length === 0) return '';
const skillTags = skills.map((skill) => skillPrompt(skill)).join('\n');
const hasProjectSkill = skills.some((skill) => skill.source === 'project');
const projectHint = hasProjectSkill
? `\nFor a skill with source="project", load it by calling the readFile tool on its \`location\` path before following its instructions.`
: '';
return `<available_skills>
${skillTags}
</available_skills>
Use the runSkill tool to activate a skill when needed.`;
Use the runSkill tool to activate a skill when needed.${projectHint}`;
};
@@ -31,6 +31,22 @@ export interface ExecAgentAppContext {
topicId?: string | null;
}
/**
* A project-level skill discovered on the device filesystem
* (`.agents/skills` / `.claude/skills`) by the client at request time.
* Only frontmatter + the absolute SKILL.md path are carried; the SKILL.md
* body and directory tree are loaded on demand at activation time via the
* readFile / listFiles tools.
*/
export interface ProjectSkillMeta {
/** Skill description from SKILL.md frontmatter. */
description?: string;
/** Skill name from frontmatter (falls back to the directory name). */
name: string;
/** Absolute path to the skill's SKILL.md on the device filesystem. */
path: string;
}
/**
* Parameters for execAgent - execute a single Agent
* Either agentId or slug must be provided
@@ -71,6 +87,13 @@ export interface ExecAgentParams {
* sub-tree back to its root.
*/
parentOperationId?: string;
/**
* Project-level skills discovered on the device filesystem
* (`.agents/skills` / `.claude/skills`) at request time. Surfaced in the
* `<available_skills>` list and loaded on demand via the readFile tool.
* Only applied when a device is active for this run.
*/
projectSkills?: ProjectSkillMeta[];
/** The user input/prompt */
prompt: string;
/** Override the agent's default provider */
+8
View File
@@ -53,6 +53,14 @@ export interface BuiltinSkill {
*/
resources?: Record<string, SkillResourceMeta>;
source: 'builtin';
/**
* Optional friendly title for UI display. When unset, the inspector and
* render layers fall back to `name` (which carries the raw identifier).
* Agent-document skill bundles (`agent-skills:<filename>`) set this so the
* activateSkill result shows e.g. "LOBE Annotation Cleanup" instead of
* the raw `agent-skills:lobe-annotation-cleanup`.
*/
title?: string;
}
// ===== Skill Source =====
@@ -59,21 +59,23 @@ export class ActionTagPlugin {
// Wire format collapses to <skill>; the `agent-skills:`
// prefix in the identifier is what the runtime keys off to
// route the activation through agentDocumentsService.
// ProjectSkill → <skill name="<skill-name>" label="..." />
// Same wire format as a registered skill — the project
// skill is in the runtime's `<available_skills>` registry
// (added on the server when a device is active), so the
// model resolves it through `activateSkill` like any
// other. Keeps the rendered prompt uniform across skill
// sources, which the LiteXML round-trip preserves via the
// category-aware `<projectSkill>` save format.
// Tools → <tool name="..." label="..." />
// ProjectSkill → bare label text (e.g. `/local-testing`) so the downstream
// CLI agent recognizes its own slash-style skill invocation
// Commands → <action type="..." category="command" label="..." />
mdService?.registerMarkdownWriter(ActionTagNode.getType(), (ctx: any, node: any) => {
if ($isActionTagNode(node)) {
const cat = node.actionCategory;
if (cat === 'skill' || cat === 'agentSkill') {
if (cat === 'skill' || cat === 'agentSkill' || cat === 'projectSkill') {
ctx.appendLine(`<skill name="${node.actionType}" label="${node.actionLabel}" />`);
} else if (cat === 'tool') {
ctx.appendLine(`<tool name="${node.actionType}" label="${node.actionLabel}" />`);
} else if (cat === 'projectSkill') {
// Chip / menu render the bare skill name; the slash is added here so
// the downstream CLI sees `/skill-name` as a slash invocation.
ctx.appendLine(`/${node.actionType}`);
} else {
ctx.appendLine(
`<action type="${node.actionType}" category="${cat}" label="${node.actionLabel}" />`,
@@ -2,6 +2,7 @@ import { isDesktop } from '@lobechat/const';
import { type ListProjectSkillsResult, type ProjectSkillItem } from '@lobechat/electron-client-ipc';
import type { IEditor, SlashOptions } from '@lobehub/editor';
import { SkillsIcon } from '@lobehub/ui/icons';
import isEqual from 'fast-deep-equal';
import Fuse from 'fuse.js';
import { $getSelection, $isRangeSelection } from 'lexical';
import { ArchiveIcon, MessageSquarePlusIcon } from 'lucide-react';
@@ -14,6 +15,9 @@ import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useToolStore } from '@/store/tool';
import { agentDocumentSkillsSelectors } from '@/store/tool/selectors';
import type { AgentDocumentSkillItem } from '@/store/tool/slices/agentDocumentSkills/initialState';
import { useAgentId } from '../../hooks/useAgentId';
import { useChatInputStore } from '../../store';
@@ -41,26 +45,33 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
const editorInstance = useChatInputStore((s) => s.editor);
const activeTopicId = useChatStore((s) => s.activeTopicId);
// Resolve hetero-agent working directory so we can surface its project skills.
// Topic-level override takes precedence over the agent's configured cwd.
// Resolve the active working directory so we can surface filesystem project
// skills. Topic-level override takes precedence over the agent's configured
// cwd. Both homogeneous and heterogeneous runtimes accept project skills now
// (see commit dd4a4e7595), so we no longer gate on the hetero provider.
const agentId = useAgentId();
const isHetero = useAgentStore((s) =>
agentId ? !!agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider : false,
);
const agentWorkingDirectory = useAgentStore((s) =>
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
);
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
const skillsEnabled = isDesktop && isHetero && !!workingDirectory;
const projectSkillsEnabled = isDesktop && !!workingDirectory;
const { data: projectSkillsData } = useClientDataSWR<ListProjectSkillsResult>(
skillsEnabled ? ['project-skills', workingDirectory] : null,
projectSkillsEnabled ? ['project-skills', workingDirectory] : null,
() => localFileService.listProjectSkills({ scope: workingDirectory! }),
{ revalidateOnFocus: false, shouldRetryOnError: false },
);
const projectSkills = projectSkillsData?.skills;
// Agent-document skill bundles (the "Agent skills" group in the working
// sidebar). Share the SWR key with the sidebar fetch so we don't double-fetch.
useToolStore((s) => s.useFetchAgentDocumentSkills)(agentId);
const agentDocumentSkills = useToolStore(
agentDocumentSkillsSelectors.getAgentDocumentSkills,
isEqual,
);
// Installed skills shared with the @ mention menu (builtin / lobehub / market / user agent skills).
// Tools intentionally stay out of slash — they remain @-mention only.
const installedSkillsAndTools = useInstalledSkillsAndTools();
@@ -126,6 +137,30 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
},
});
const makeAgentSkillItem = (skill: AgentDocumentSkillItem): SlashMenuOption => {
const label = skill.title || skill.name;
return {
icon: SkillsIcon,
// Identifier already carries the `agent-skills:` prefix, which keeps it
// unique against project / builtin skills.
key: `agent-skill-${skill.identifier}`,
label,
metadata: {
category: 'agentSkill',
description: skill.description,
type: skill.identifier,
},
onSelect: (editor: IEditor) => {
const payload: InsertActionTagPayload = {
category: 'agentSkill',
label,
type: skill.identifier,
};
editor.dispatchCommand(INSERT_ACTION_TAG_COMMAND, payload);
},
};
};
// Trigger position:
// - line-start → commands + installed skills + project skills
// - mid-line w/ preceding whitespace → installed skills + project skills only (no commands)
@@ -175,7 +210,16 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
allItems.push(makeSkillItem(skill) as SlashItem);
}
// Hetero-agent project skills (file-system based, resolved by the CLI agent itself)
// Agent-document skill bundles (per-agent, resolved server-side via the
// `agent-skills:<filename>` identifier prefix).
for (const skill of agentDocumentSkills) {
allItems.push(makeAgentSkillItem(skill) as SlashItem);
}
// Filesystem project skills (`.agents/skills/` / `.claude/skills/` under
// the working directory). Both homogeneous and heterogeneous runtimes
// resolve them — the homogeneous runtime treats them as additional
// `<available_skills>` entries.
if (projectSkills && projectSkills.length > 0) {
for (const skill of projectSkills) {
allItems.push(makeProjectSkillItem(skill) as SlashItem);
@@ -190,6 +234,6 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
return allItems;
},
[t, editorInstance, activeTopicId, projectSkills, installedSkills],
[t, editorInstance, activeTopicId, projectSkills, installedSkills, agentDocumentSkills],
);
};
+36 -19
View File
@@ -7,8 +7,16 @@ import { memo, useCallback, useMemo, useState } from 'react';
export interface SkillListItem {
description?: string;
fileCount: number;
files: string[];
/**
* File count shown next to the name. Omit (or pass 0) for atomic skills with
* no embedded files — e.g. user-level skills sourced from MCP servers.
*/
fileCount?: number;
/**
* Optional file tree for skills that bundle markdown/script assets. When
* empty or omitted the row stays atomic (no chevron, no expansion).
*/
files?: string[];
id: string;
name: string;
}
@@ -221,7 +229,9 @@ interface SkillRowProps {
const SkillRow = memo<SkillRowProps>(
({ expanded, item, onDragStart, onOpenFile, onOpenSkill, onToggle }) => {
const tree = useMemo(() => buildSkillTree(item.files), [item.files]);
const files = item.files ?? [];
const hasFiles = files.length > 0;
const tree = useMemo(() => buildSkillTree(files), [files]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const toggleFolder = useCallback((folderPath: string) => {
@@ -251,29 +261,36 @@ const SkillRow = memo<SkillRowProps>(
gap={6}
onDragStart={onDragStart}
>
<Flexbox
align={'center'}
justify={'center'}
style={{ cursor: 'pointer', flexShrink: 0, height: 20, width: 20 }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<Icon
className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ''}`}
icon={ChevronRightIcon}
size={14}
/>
</Flexbox>
{hasFiles ? (
<Flexbox
align={'center'}
justify={'center'}
style={{ cursor: 'pointer', flexShrink: 0, height: 20, width: 20 }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<Icon
className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ''}`}
icon={ChevronRightIcon}
size={14}
/>
</Flexbox>
) : (
<span style={{ flexShrink: 0, height: 20, width: 20 }} />
)}
<Icon className={styles.itemIcon} icon={SkillsIcon} size={14} />
<Text ellipsis style={{ color: 'inherit', flex: 1, minWidth: 0 }} onClick={onOpenSkill}>
{item.name}
</Text>
<span className={styles.itemCount}>{item.fileCount}</span>
{typeof item.fileCount === 'number' && item.fileCount > 0 && (
<span className={styles.itemCount}>{item.fileCount}</span>
)}
</Flexbox>
</Tooltip>
{expanded &&
hasFiles &&
onOpenFile &&
tree.map((node) => (
<TreeRow
+3 -4
View File
@@ -1019,8 +1019,7 @@ export default {
'workingPanel.resources.deleteError': 'Failed to delete document',
'workingPanel.resources.deleteSuccess': 'Document deleted',
'workingPanel.resources.deleteTitle': 'Delete document?',
'workingPanel.resources.empty':
'No documents yet. Documents associated with this agent will show up here.',
'workingPanel.resources.empty': 'No webpages. Webpages crawled in this agent will show up here.',
'workingPanel.resources.error': 'Failed to load resources',
'workingPanel.resources.filter.documents': 'Documents',
'workingPanel.resources.filter.skills': 'Skills',
@@ -1051,10 +1050,10 @@ export default {
'workingPanel.localFile.preview.raw': 'Raw',
'workingPanel.localFile.preview.render': 'Preview',
'workingPanel.localFile.truncated': 'File preview truncated to {{limit}} characters',
'workingPanel.skills.empty': 'No skills found in this project',
'workingPanel.skills.emptyAgent': 'No skills attached to this agent',
'workingPanel.skills.empty': 'No skills available',
'workingPanel.skills.section.agent': 'Agent skills',
'workingPanel.skills.section.project': 'Project skills',
'workingPanel.skills.section.user': 'User skills',
'workingPanel.skills.title': 'Skills',
'workingPanel.files.count_one': '{{count}} file',
'workingPanel.files.count_other': '{{count}} files',
+2
View File
@@ -267,6 +267,8 @@ export default {
'builtins.lobe-skills.apiName.readReference': 'Read Reference',
'builtins.lobe-skills.apiName.runCommand': 'Run Command',
'builtins.lobe-skills.apiName.activateSkill': 'Activate Skill',
'builtins.lobe-skills.apiName.activateAgentSkill': 'Activate Agent Skill',
'builtins.lobe-skills.apiName.activateProjectSkill': 'Activate Project Skill',
'builtins.lobe-skills.apiName.searchSkill': 'Search Skills',
'builtins.lobe-skills.title': 'Skills',
'builtins.lobe-task.apiName.createTask': 'Create task',
@@ -72,6 +72,9 @@ vi.mock('react-i18next', () => ({
'workingPanel.resources.filter.web': 'Web',
'workingPanel.resources.updatedAt': `Updated ${options?.time}`,
'workingPanel.skills.empty': 'No skills found',
'workingPanel.skills.section.agent': 'Agent skills',
'workingPanel.skills.section.project': 'Project skills',
'workingPanel.skills.section.user': 'User skills',
}) as Record<string, string>
)[key] || key,
}),
@@ -89,7 +92,7 @@ vi.mock('@/features/AgentDocumentsExplorer', () => ({
}));
vi.mock('@/features/SkillsList', () => {
type Item = { fileCount: number; id: string; name: string };
type Item = { fileCount?: number; id: string; name: string };
const SkillsList = ({
items,
onOpenFile,
@@ -114,15 +117,19 @@ vi.mock('@/features/SkillsList', () => {
))}
</div>
);
// SkillSection is a thin presentational wrapper — the real one only adds
// an Accordion / header. For these tests we pass children through and
// surface the count so assertions that key off the section header still
// work.
// SkillSection is a thin presentational wrapper — the real one swaps the
// children for an empty placeholder when `isEmpty` is true. We mirror that
// here so co-located assertions (e.g. "skills-list does not render when no
// bundles are present") line up with production behavior.
const SkillSection = ({
children,
emptyText,
isEmpty,
sectionHeader,
}: {
children?: ReactNode;
emptyText?: string;
isEmpty?: boolean;
sectionHeader?: { count?: number; title: string };
}) => (
<div data-testid={sectionHeader ? `skill-section-${sectionHeader.title}` : 'skill-section'}>
@@ -132,7 +139,7 @@ vi.mock('@/features/SkillsList', () => {
{typeof sectionHeader.count === 'number' && <span>{sectionHeader.count}</span>}
</div>
)}
{children}
{isEmpty ? <div data-testid="skill-section-empty">{emptyText}</div> : children}
</div>
);
const useProjectSkills = () => ({
@@ -145,6 +152,19 @@ vi.mock('@/features/SkillsList', () => {
return { SkillSection, SkillsList, useProjectSkills };
});
// UserLevelSkills owns its own store wiring and is exercised separately. We
// stub it (component + the lifted hook) so AgentDocumentsGroup's render logic
// can be tested without dragging the tool store into the working-sidebar
// tests. Default to an empty user-skill list so the empty-state branch is
// reachable; individual tests can re-mock before render to override.
vi.mock('./UserLevelSkills', () => ({
default: () => null,
useUserSkills: () => [],
}));
vi.mock('@/features/ChatInput/InputEditor/ActionTag/skillDragData', () => ({
startSkillDrag: () => undefined,
}));
vi.mock('@/services/agentDocument', () => ({
agentDocumentSWRKeys: {
documents: (agentId: string) => ['agent-documents', agentId],
@@ -393,7 +413,7 @@ describe('AgentDocumentsGroup', () => {
expect(openDocument).not.toHaveBeenCalled();
});
it('shows the skills empty state when no bundles are present', () => {
it('falls back to a single empty placeholder when every skill source is empty', () => {
useClientDataSWR.mockReturnValue({
data: [fileDocRow, webDocRow],
error: undefined,
@@ -403,6 +423,9 @@ describe('AgentDocumentsGroup', () => {
render(<AgentDocumentsGroup />);
// No agent bundles, no working dir (no Project section), no user-installed
// skills → renderSkills collapses to the global "No skills found"
// placeholder rather than rendering an empty section per source.
expect(screen.getByText('No skills found')).toBeInTheDocument();
expect(screen.queryByTestId('skills-list')).not.toBeInTheDocument();
});
@@ -15,7 +15,12 @@ import { useMatch, useNavigate } from 'react-router-dom';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { DocumentExplorerTree } from '@/features/AgentDocumentsExplorer';
import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skillDragData';
import { type SkillListItem, SkillSection, SkillsList } from '@/features/SkillsList';
import {
type SkillListItem,
SkillSection,
SkillsList,
useProjectSkills,
} from '@/features/SkillsList';
import { useClientDataSWR } from '@/libs/swr';
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
import { useAgentStore } from '@/store/agent';
@@ -24,6 +29,7 @@ import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import ProjectLevelSkills from './ProjectLevelSkills';
import UserLevelSkills, { useUserSkills } from './UserLevelSkills';
const PAGE_ROUTE_PATTERN = '/agent/:aid/:topicId/page/:docId?';
@@ -272,6 +278,14 @@ const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ style, workingDire
const showProjectSkills = isLocalEnabled && !!workingDirectory;
// Mirror what each child component reads so the parent can decide the
// section layout (flat when a single source has items, sectioned otherwise).
// Both hooks are SWR-deduped against their respective child fetches.
const userSkillItems = useUserSkills();
const { items: projectSkillItems } = useProjectSkills(
showProjectSkills ? workingDirectory : undefined,
);
const {
data = [],
error,
@@ -358,33 +372,45 @@ const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ style, workingDire
);
const renderSkills = () => {
// No project section (not local mode / no working dir): show the agent
// skills flat, without the redundant "Agent skills" group header.
if (!showProjectSkills) {
if (skillItems.length === 0) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
</Center>
);
}
return renderAgentSkillsList();
// Sections render in fixed order — agent → project → user — and each one
// hides itself when it has nothing to show. When exactly one source has
// items we drop the group header and render the list flat (no redundant
// "User skills 1" label above a single row). When everything is empty we
// fall back to a single placeholder.
const hasAgent = skillItems.length > 0;
const hasProject = showProjectSkills && projectSkillItems.length > 0;
const hasUser = userSkillItems.length > 0;
const activeCount = (hasAgent ? 1 : 0) + (hasProject ? 1 : 0) + (hasUser ? 1 : 0);
if (activeCount === 0) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
</Center>
);
}
// Both sections coexist — label each so the source is clear.
const flat = activeCount === 1;
return (
<Flexbox gap={16}>
<SkillSection
emptyText={t('workingPanel.skills.emptyAgent')}
isEmpty={skillItems.length === 0}
sectionHeader={{
count: skillItems.length,
title: t('workingPanel.skills.section.agent'),
}}
>
{renderAgentSkillsList()}
</SkillSection>
<ProjectLevelSkills workingDirectory={workingDirectory!} />
<Flexbox gap={16} style={{ paddingBottom: 16 }}>
{hasAgent &&
(flat ? (
renderAgentSkillsList()
) : (
<SkillSection
sectionHeader={{
count: skillItems.length,
title: t('workingPanel.skills.section.agent'),
}}
>
{renderAgentSkillsList()}
</SkillSection>
))}
{hasProject && (
<ProjectLevelSkills hideHeader={flat} workingDirectory={workingDirectory!} />
)}
{hasUser && <UserLevelSkills hideHeader={flat} />}
</Flexbox>
);
};
@@ -5,37 +5,47 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill
import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList';
interface ProjectLevelSkillsProps {
/**
* Skip the `SkillSection` wrapper (no header row). Set when the parent has
* collapsed to a single visible source and wants the list rendered flat.
*/
hideHeader?: boolean;
workingDirectory: string;
}
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(({ workingDirectory }) => {
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(({ hideHeader, workingDirectory }) => {
const { t } = useTranslation('chat');
const { isLoading, items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
if (items.length === 0) return null;
const list = (
<SkillsList
items={items}
onOpenFile={onOpenFile}
onOpenSkill={onOpenSkill}
onSkillDragStart={(item, event) => {
// Project skills are resolved by the underlying CLI agent itself, so
// we serialize them as a literal `/skill-name` (projectSkill chip).
startSkillDrag(event, {
category: 'projectSkill',
label: item.name,
type: item.name,
});
}}
/>
);
if (hideHeader) return list;
return (
<SkillSection
emptyText={t('workingPanel.skills.empty')}
isEmpty={items.length === 0}
isLoading={isLoading}
sectionHeader={{
count: items.length,
title: t('workingPanel.skills.section.project'),
}}
>
<SkillsList
items={items}
onOpenFile={onOpenFile}
onOpenSkill={onOpenSkill}
onSkillDragStart={(item, event) => {
// Project skills are resolved by the underlying CLI agent itself, so
// we serialize them as a literal `/skill-name` (projectSkill chip).
startSkillDrag(event, {
category: 'projectSkill',
label: item.name,
type: item.name,
});
}}
/>
{list}
</SkillSection>
);
});
@@ -0,0 +1,82 @@
import isEqual from 'fast-deep-equal';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skillDragData';
import { type SkillListItem, SkillSection, SkillsList } from '@/features/SkillsList';
import { useToolStore } from '@/store/tool';
import { agentSkillsSelectors } from '@/store/tool/selectors';
/**
* Reads user-installed skills (entries in the `agent_skill` table market
* imports plus user-created customs) into the `SkillsList` row shape. Builtin
* tools and LobeHub MCP servers are intentionally excluded those belong in
* the Tools popover, not in the per-user skill inventory.
*
* Also triggers the underlying SWR fetch so the working sidebar surfaces the
* data even when the Tools popover hasn't been opened in this session. The key
* is deduplicated, so co-mounting with `useControls` doesn't double-fetch.
*/
export const useUserSkills = (): SkillListItem[] => {
useToolStore((s) => s.useFetchAgentSkills)(true);
const agentSkills = useToolStore(agentSkillsSelectors.getAgentSkills, isEqual);
return useMemo(
() =>
agentSkills.map((skill) => ({
description: skill.description ?? undefined,
// `identifier` is what the runtime resolves through the skill registry,
// and is unique per skill — reuse it as both the React key and the
// drag payload's `type`.
id: skill.identifier,
name: skill.name,
})),
[agentSkills],
);
};
interface UserLevelSkillsProps {
/**
* Skip the `SkillSection` wrapper (no header row). Set when the parent has
* collapsed to a single visible source and wants the list rendered flat,
* matching the agent-only layout this used to ship with.
*/
hideHeader?: boolean;
}
const UserLevelSkills = memo<UserLevelSkillsProps>(({ hideHeader }) => {
const { t } = useTranslation('chat');
const items = useUserSkills();
if (items.length === 0) return null;
const list = (
<SkillsList
items={items}
onSkillDragStart={(item, event) => {
startSkillDrag(event, {
category: 'skill',
label: item.name,
type: item.id,
});
}}
/>
);
if (hideHeader) return list;
return (
<SkillSection
sectionHeader={{
count: items.length,
title: t('workingPanel.skills.section.user'),
}}
>
{list}
</SkillSection>
);
});
UserLevelSkills.displayName = 'UserLevelSkills';
export default UserLevelSkills;
@@ -1705,6 +1705,15 @@ export const createRuntimeExecutors = (
memoryToolPermission: agentConfig?.chatConfig?.memory?.toolPermission,
messageId: state.metadata?.sourceMessageId,
operationId,
projectSkills: (state.metadata?.operationSkillSet?.skills ?? [])
.filter(
(skill: { location?: string; source?: string }) =>
skill.source === 'project' && !!skill.location,
)
.map((skill: { location: string; name: string }) => ({
location: skill.location,
name: skill.name,
})),
scope: state.metadata?.scope,
serverDB: ctx.serverDB,
skipResultTruncation: true,
+16
View File
@@ -160,6 +160,20 @@ const ExecAgentSchema = z
fileIds: z.array(z.string()).optional(),
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
parentMessageId: z.string().optional(),
/**
* Project-level skills discovered on the device filesystem
* (`.agents/skills` / `.claude/skills`) by the client at request time.
* Surfaced in `<available_skills>` and loaded on demand via readFile.
*/
projectSkills: z
.array(
z.object({
description: z.string().optional(),
name: z.string(),
path: z.string(),
}),
)
.optional(),
/** The user input/prompt */
prompt: z.string(),
/**
@@ -624,6 +638,7 @@ export const aiAgentRouter = router({
existingMessageIds = [],
fileIds,
parentMessageId,
projectSkills,
resumeApproval,
trigger,
userInterventionConfig,
@@ -641,6 +656,7 @@ export const aiAgentRouter = router({
existingMessageIds,
fileIds,
parentMessageId,
projectSkills,
prompt,
// When parentMessageId is provided, this is a regeneration/continue or a
// human-approval resume — either way, skip user message creation.
@@ -650,4 +650,191 @@ describe('AgentDocumentsService', () => {
expect(result).toEqual({ id: 'ad-1' });
});
});
describe('getAgentSkills', () => {
// Inject docs with the derive flags already set so we test the
// bundle → index-child → identifier mapping in isolation, not the
// model's deriveAgentDocumentFields projection.
const stubDocs = (docs: Array<Partial<any>>): any[] =>
docs.map((doc) => ({
content: '',
description: null,
filename: '',
isSkillBundle: false,
isSkillIndex: false,
parentId: null,
title: null,
...doc,
}));
it('returns an empty list when the agent has no skill bundles', async () => {
const service = new AgentDocumentsService(db, userId);
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
stubDocs([
{ documentId: 'doc-1', filename: 'note.md', isSkillBundle: false },
{ documentId: 'doc-2', filename: 'web.md', isSkillBundle: false },
]),
);
const result = await service.getAgentSkills('agent-1');
expect(service.getAgentDocuments).toHaveBeenCalledWith('agent-1');
expect(result).toEqual([]);
});
it('prefixes the identifier with `agent-skills:` and pulls content from the SKILL.md index child', async () => {
const service = new AgentDocumentsService(db, userId);
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
stubDocs([
{
content: '',
description: 'Triage workflow',
documentId: 'bundle-1',
filename: 'bug-triage',
isSkillBundle: true,
title: 'Bug Triage',
},
{
content: '# Bug triage\n\nbody',
documentId: 'index-1',
filename: 'SKILL.md',
isSkillIndex: true,
parentId: 'bundle-1',
},
// Sibling non-index child — must be ignored.
{
content: 'reference',
documentId: 'asset-1',
filename: 'reference.md',
parentId: 'bundle-1',
},
]),
);
const result = await service.getAgentSkills('agent-1');
expect(result).toEqual([
{
content: '# Bug triage\n\nbody',
description: 'Triage workflow',
filename: 'bug-triage',
identifier: 'agent-skills:bug-triage',
name: 'agent-skills:bug-triage',
title: 'Bug Triage',
},
]);
});
it('falls back to the bundle row content when the index child is missing', async () => {
const service = new AgentDocumentsService(db, userId);
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
stubDocs([
{
content: 'orphan body',
description: null,
documentId: 'orphan-1',
filename: 'orphan-skill',
isSkillBundle: true,
title: 'Orphan',
},
]),
);
const result = await service.getAgentSkills('agent-1');
expect(result).toEqual([
{
content: 'orphan body',
description: '',
filename: 'orphan-skill',
identifier: 'agent-skills:orphan-skill',
name: 'agent-skills:orphan-skill',
title: 'Orphan',
},
]);
});
it('emits empty content for a bundle with no index child and no body', async () => {
const service = new AgentDocumentsService(db, userId);
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
stubDocs([
{
content: '',
documentId: 'empty-1',
filename: 'empty',
isSkillBundle: true,
title: 'Empty',
},
]),
);
const [skill] = await service.getAgentSkills('agent-1');
expect(skill.content).toBe('');
expect(skill.identifier).toBe('agent-skills:empty');
});
it('returns one entry per skill bundle and ignores non-bundle docs', async () => {
const service = new AgentDocumentsService(db, userId);
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
stubDocs([
{
documentId: 'b-1',
filename: 'one',
isSkillBundle: true,
title: 'One',
},
{
content: 'one body',
documentId: 'b-1-idx',
isSkillIndex: true,
parentId: 'b-1',
},
{
documentId: 'b-2',
filename: 'two',
isSkillBundle: true,
title: 'Two',
},
{
content: 'two body',
documentId: 'b-2-idx',
isSkillIndex: true,
parentId: 'b-2',
},
// Unrelated regular doc.
{ documentId: 'note', filename: 'note.md' },
]),
);
const result = await service.getAgentSkills('agent-1');
expect(result.map((s) => s.identifier)).toEqual(['agent-skills:one', 'agent-skills:two']);
expect(result.map((s) => s.content)).toEqual(['one body', 'two body']);
});
it('matches index children strictly by parentId — does not leak across bundles', async () => {
const service = new AgentDocumentsService(db, userId);
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
stubDocs([
{ documentId: 'b-1', filename: 'first', isSkillBundle: true },
{ documentId: 'b-2', filename: 'second', isSkillBundle: true },
// Only b-2 has an index child; b-1 must fall back to its own (empty)
// content rather than borrow b-2's content.
{
content: 'second body',
documentId: 'b-2-idx',
isSkillIndex: true,
parentId: 'b-2',
},
]),
);
const result = await service.getAgentSkills('agent-1');
expect(result).toHaveLength(2);
expect(result.find((s) => s.filename === 'first')?.content).toBe('');
expect(result.find((s) => s.filename === 'second')?.content).toBe('second body');
});
});
});
+31 -1
View File
@@ -2069,9 +2069,39 @@ export class AiAgentService {
name: skill.name,
}));
// Project skills are filesystem SKILL.md discovered on the device. They
// are only meaningful when a device is active (readFile resolves against
// it). Only `location` (absolute SKILL.md path) flows through — the
// skill's directory tree is enumerated lazily at activation time via
// `local-system.listFiles` over the device gateway, keeping the op-param
// payload small.
const projectMetas =
activeDeviceId && params.projectSkills?.length
? params.projectSkills.map((s) => ({
description: s.description ?? '',
identifier: `project:${s.name}`,
location: s.path,
name: s.name,
source: 'project' as const,
}))
: [];
// Precedence on name collision: project > db > agent-skills > builtin.
// Agent-skills carry the `agent-skills:` prefix in their `name`, so they
// can only collide with each other — but we still dedupe by name to keep
// a single shape for the SkillEngine input.
const seenNames = new Set<string>();
const skills = [...projectMetas, ...dbMetas, ...agentSkillMetas, ...builtinMetas].filter(
(skill) => {
if (seenNames.has(skill.name)) return false;
seenNames.add(skill.name);
return true;
},
);
const skillEngine = new SkillEngine({
enableChecker: (skill) => shouldEnableBuiltinSkill(skill.identifier),
skills: [...builtinMetas, ...dbMetas, ...agentSkillMetas],
skills,
});
operationSkillSet = skillEngine.generate(agentPlugins ?? []);
} catch (error) {
@@ -1,6 +1,10 @@
import { builtinSkills } from '@lobechat/builtin-skills';
import { LocalSystemApiName, LocalSystemIdentifier } from '@lobechat/builtin-tool-local-system';
// Note: only `readFile` is wired through deviceProxy. Directory enumeration is
// left to the model via `local-system.listFiles` so we don't double-fetch.
import { type CommandResult, SkillsIdentifier } from '@lobechat/builtin-tool-skills';
import {
type DeviceFileAccess,
type ExportFileResult,
type SkillRuntimeService,
SkillsExecutionRuntime,
@@ -21,6 +25,7 @@ import { MarketService } from '@/server/services/market';
import { SkillResourceService } from '@/server/services/skill/resource';
import { preprocessLhCommand } from '@/server/services/toolExecution/preprocessLhCommand';
import { deviceProxy } from '../deviceProxy';
import { type ServerRuntimeRegistration } from './types';
const log = debug('lobe-server:skills-runtime');
@@ -350,7 +355,9 @@ export const skillsRuntime: ServerRuntimeRegistration = {
// the identifier prefix and the bundle → index-child content resolution
// (also used by `aiAgent/index.ts` when building `<available_skills>`).
// `source: 'builtin'` is the type-system carrier shape required by
// `BuiltinSkill`; the runtime never reads `source`.
// `BuiltinSkill`; the runtime re-tags `source: 'agent'` in the activateSkill
// result based on the identifier prefix so the inspector can show
// "Activate Agent Skill" + the friendly `title`.
const agentSkillBuiltins: BuiltinSkill[] = context.agentId
? await new AgentDocumentsService(context.serverDB, context.userId)
.getAgentSkills(context.agentId)
@@ -361,6 +368,7 @@ export const skillsRuntime: ServerRuntimeRegistration = {
identifier: skill.identifier,
name: skill.name,
source: 'builtin' as const,
...(skill.title && { title: skill.title }),
})),
)
.catch((error) => {
@@ -369,8 +377,70 @@ export const skillsRuntime: ServerRuntimeRegistration = {
})
: [];
// Project skills live on the device filesystem. Read them through the
// device gateway by reusing the local-system tools — no special
// file-read primitive, just the existing capabilities over deviceProxy.
// - `readFile` loads SKILL.md and validated reference files.
// - `globFiles` enumerates the skill directory so `readReference` can
// reject paths the model guessed (e.g. `.env`) instead of trusting
// the raw string. The discovery payload no longer carries the file
// tree (see commit 8e8f3aed14), so we enumerate live at read time.
const { activeDeviceId, projectSkills } = context;
let deviceFileAccess: DeviceFileAccess | undefined;
if (activeDeviceId && context.userId) {
const userId = context.userId;
deviceFileAccess = {
listFiles: async (dir: string) => {
const result = await deviceProxy.executeToolCall(
{ deviceId: activeDeviceId, userId },
{
apiName: LocalSystemApiName.globFiles,
// `**/*` matches every regular file recursively under `dir`.
// The device-side enumerator already skips hidden files; the
// runtime re-checks segments as defense in depth.
arguments: JSON.stringify({ pattern: '**/*', scope: dir }),
identifier: LocalSystemIdentifier,
},
);
if (!result.success) {
throw new Error(result.error || result.content || `globFiles failed: ${dir}`);
}
let payload: { files?: unknown };
try {
payload = JSON.parse(result.content) as { files?: unknown };
} catch {
throw new Error(`globFiles returned a non-JSON payload for ${dir}`);
}
if (!Array.isArray(payload.files)) return [];
// Files come back as paths relative to `scope` (POSIX). Strip any
// absolute path the engine may have emitted so the runtime can
// compare against normalized user-supplied relative paths.
return payload.files
.filter((f): f is string => typeof f === 'string')
.map((f) => (f.startsWith(dir) ? f.slice(dir.length).replace(/^[/\\]+/, '') : f));
},
readFile: async (filePath: string) => {
const result = await deviceProxy.executeToolCall(
{ deviceId: activeDeviceId, userId },
{
apiName: LocalSystemApiName.readFile,
// Read the whole file; SKILL.md and references are small.
arguments: JSON.stringify({ loc: [0, 5000], path: filePath }),
identifier: LocalSystemIdentifier,
},
);
if (!result.success) {
throw new Error(result.error || result.content || `readFile failed: ${filePath}`);
}
return result.content;
},
};
}
return new SkillsExecutionRuntime({
builtinSkills: [...filterBuiltinSkills(builtinSkills), ...agentSkillBuiltins],
deviceFileAccess,
projectSkills,
service,
});
},
@@ -32,6 +32,12 @@ export interface ToolExecutionContext {
messageId?: string;
/** Agent runtime operation ID for structured tool outcome identity. */
operationId?: string;
/**
* Project-level skills (name + absolute SKILL.md path) discovered on the
* device filesystem. Used by the Skills runtime to load them on demand via
* the device gateway. Derived from the operation's skill set.
*/
projectSkills?: { location: string; name: string }[];
/** Conversation scope captured when the operation was created */
scope?: string | null;
/** Server database for LobeHub Skills execution */
+6
View File
@@ -40,6 +40,12 @@ export interface ExecAgentTaskParams {
fileIds?: string[];
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
parentMessageId?: string;
/**
* Project-level skills discovered on the device filesystem
* (`.agents/skills` / `.claude/skills`). Surfaced in `<available_skills>`
* and loaded on demand via the readFile tool.
*/
projectSkills?: { description?: string; name: string; path: string }[];
prompt: string;
/** Resume a previous op paused on `human_approve_required` instead of starting from a fresh user prompt. */
resumeApproval?: ResumeApprovalParam;
@@ -8,15 +8,51 @@ import type { ConversationContext, ExecAgentResult, MessageMetadata } from '@lob
import { isDesktop } from '@/const/version';
import { aiAgentService, type ResumeApprovalParam } from '@/services/aiAgent';
import { localFileService } from '@/services/electron/localFileService';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
import { getAgentStoreState } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { consumePendingTopicRepos, getPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
import { topicSelectors } from '@/store/chat/selectors';
import type { ChatStore } from '@/store/chat/store';
import type { StoreSetter } from '@/store/types';
import { useUserStore } from '@/store/user';
import { createGatewayEventHandler } from './gatewayEventHandler';
/**
* Scan the active working directory for project-level skills
* (`.agents/skills` / `.claude/skills`) so the server can surface them in
* `<available_skills>`. Desktop-only and best-effort: a failed scan must not
* block the send.
*/
const resolveProjectSkills = async (
get: () => ChatStore,
): Promise<{ description?: string; name: string; path: string }[] | undefined> => {
if (!isDesktop) return undefined;
const topicWorkingDirectory = topicSelectors.currentTopicWorkingDirectory(get());
const agentWorkingDirectory = agentSelectors.currentAgentWorkingDirectory(getAgentStoreState());
const workingDirectory = topicWorkingDirectory ?? agentWorkingDirectory;
if (!workingDirectory) return undefined;
try {
const { skills } = await localFileService.listProjectSkills({ scope: workingDirectory });
if (skills.length === 0) return undefined;
// The directory tree is enumerated lazily at activation time by the Skills
// runtime (via the local-system `listFiles` tool), so we drop `files` here
// — keeps the op-param payload small.
return skills.map((skill) => ({
description: skill.description,
name: skill.name,
path: skill.path,
}));
} catch {
return undefined;
}
};
type Setter = StoreSetter<ChatStore>;
// ─── Types ───
@@ -317,6 +353,8 @@ export class GatewayActionImpl {
? this.#get().getOperationAbortSignal(parentOperationId)
: undefined;
const projectSkills = await resolveProjectSkills(this.#get);
const result = await aiAgentService.execAgentTask(
{
agentId: context.agentId,
@@ -336,6 +374,7 @@ export class GatewayActionImpl {
clientRuntime: isDesktop ? 'desktop' : 'web',
fileIds,
parentMessageId,
projectSkills,
prompt: message,
resumeApproval,
trigger: metadata?.trigger,