diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index a2843b2b3f..0cd0358709 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -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", diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index e6997a03c7..afb7821ee2 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -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", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 6e4317babb..76d2531e77 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -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": "工作面板", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index fa07b44496..380f628827 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -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": "导出文件", diff --git a/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx b/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx index ae24a890d5..976d05743c 100644 --- a/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx +++ b/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx @@ -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 (
- {t('builtins.lobe-skills.apiName.activateSkill')} + {label}
); return (
- {t('builtins.lobe-skills.apiName.activateSkill')}: + {label}: {displayName} @@ -73,7 +93,7 @@ export const ActivateSkillInspector = memo< return (
- {t('builtins.lobe-skills.apiName.activateSkill')}: + {label}: {displayName && ( diff --git a/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx b/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx index 7a50a79650..6f6fadb9f6 100644 --- a/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx +++ b/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx @@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ const ActivateSkill = memo>( ({ content, pluginState }) => { - const { description, name } = pluginState || {}; + const { description, name, title } = pluginState || {}; + const displayName = title || name; - if (!name) return null; + if (!displayName) return null; return ( - {name} + {displayName} {description && {description}} {content && ( diff --git a/packages/builtin-tool-activator/src/index.ts b/packages/builtin-tool-activator/src/index.ts index b0fa7c5c53..eb5b8b9621 100644 --- a/packages/builtin-tool-activator/src/index.ts +++ b/packages/builtin-tool-activator/src/index.ts @@ -3,6 +3,7 @@ export { systemPrompt } from './systemRole'; export { type ActivatedToolInfo, type ActivateSkillParams, + type ActivateSkillSource, type ActivateSkillState, type ActivateToolsParams, type ActivateToolsState, diff --git a/packages/builtin-tool-activator/src/types.ts b/packages/builtin-tool-activator/src/types.ts index eb0f9aac93..4bebd2bb4b 100644 --- a/packages/builtin-tool-activator/src/types.ts +++ b/packages/builtin-tool-activator/src/types.ts @@ -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; } diff --git a/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts b/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts index 135b8538b8..1cb9982dfd 100644 --- a/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts +++ b/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts @@ -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' }); + }); + }); }); diff --git a/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts b/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts index 21084ab51d..07b0a3de66 100644 --- a/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts +++ b/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts @@ -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; } +/** + * 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; + /** Read a text file's content from the device. */ + readFile: (path: string) => Promise; +} + 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 { @@ -168,16 +247,87 @@ export class SkillsExecutionRuntime { async readReference(args: ReadReferenceParams): Promise { 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 `` 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 { 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 `` 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:` 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, }; } diff --git a/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx b/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx index fe8cfb04f2..84d9d81c59 100644 --- a/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx +++ b/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx @@ -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 (
- {t('builtins.lobe-skills.apiName.activateSkill')} + {label}
); return (
- {t('builtins.lobe-skills.apiName.activateSkill')}: + {label}: {displayName} @@ -73,7 +107,7 @@ export const RunSkillInspector = memo< return (
- {t('builtins.lobe-skills.apiName.activateSkill')}: + {label}: {displayName && ( diff --git a/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx b/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx index 3aea3b80b3..f44bd043c5 100644 --- a/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx +++ b/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx @@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ const RunSkill = memo>( ({ content, pluginState }) => { - const { description, name } = pluginState || {}; + const { description, name, title } = pluginState || {}; + const displayName = title || name; - if (!name) return null; + if (!displayName) return null; return ( - {name} + {displayName} {description && {description}} {content && ( diff --git a/packages/builtin-tool-skills/src/index.ts b/packages/builtin-tool-skills/src/index.ts index 631b42cd85..ebac11ed0b 100644 --- a/packages/builtin-tool-skills/src/index.ts +++ b/packages/builtin-tool-skills/src/index.ts @@ -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, diff --git a/packages/builtin-tool-skills/src/types.ts b/packages/builtin-tool-skills/src/types.ts index fa15a008b3..42b44ec08e 100644 --- a/packages/builtin-tool-skills/src/types.ts +++ b/packages/builtin-tool-skills/src/types.ts @@ -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; } /** diff --git a/packages/context-engine/src/providers/SkillContextProvider.ts b/packages/context-engine/src/providers/SkillContextProvider.ts index d6c774329d..f91bf32648 100644 --- a/packages/context-engine/src/providers/SkillContextProvider.ts +++ b/packages/context-engine/src/providers/SkillContextProvider.ts @@ -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); diff --git a/packages/prompts/src/prompts/skills/index.ts b/packages/prompts/src/prompts/skills/index.ts index 4686bf8d44..857762cb46 100644 --- a/packages/prompts/src/prompts/skills/index.ts +++ b/packages/prompts/src/prompts/skills/index.ts @@ -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//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.description}` - : ` ${skill.description}`; +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.description}`; +}; 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 ` ${skillTags} -Use the runSkill tool to activate a skill when needed.`; +Use the runSkill tool to activate a skill when needed.${projectHint}`; }; diff --git a/packages/types/src/agentExecution/index.ts b/packages/types/src/agentExecution/index.ts index c7902c9d70..0ed28801a9 100644 --- a/packages/types/src/agentExecution/index.ts +++ b/packages/types/src/agentExecution/index.ts @@ -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 + * `` 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 */ diff --git a/packages/types/src/skill/index.ts b/packages/types/src/skill/index.ts index a146bb770e..85b85fdceb 100644 --- a/packages/types/src/skill/index.ts +++ b/packages/types/src/skill/index.ts @@ -53,6 +53,14 @@ export interface BuiltinSkill { */ resources?: Record; 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:`) 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 ===== diff --git a/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts b/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts index 593c68eafa..39cd102176 100644 --- a/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts +++ b/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts @@ -59,21 +59,23 @@ export class ActionTagPlugin { // Wire format collapses to ; the `agent-skills:` // prefix in the identifier is what the runtime keys off to // route the activation through agentDocumentsService. + // ProjectSkill → + // Same wire format as a registered skill — the project + // skill is in the runtime's `` 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 `` save format. // Tools → - // ProjectSkill → bare label text (e.g. `/local-testing`) so the downstream - // CLI agent recognizes its own slash-style skill invocation // Commands → 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(``); } else if (cat === 'tool') { ctx.appendLine(``); - } 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( ``, diff --git a/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts b/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts index a1c862b140..6dfbfe68cb 100644 --- a/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts +++ b/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts @@ -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( - 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:` 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 + // `` 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], ); }; diff --git a/src/features/SkillsList/SkillsList.tsx b/src/features/SkillsList/SkillsList.tsx index 81b474412c..1c7f55d376 100644 --- a/src/features/SkillsList/SkillsList.tsx +++ b/src/features/SkillsList/SkillsList.tsx @@ -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( ({ 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>(() => new Set()); const toggleFolder = useCallback((folderPath: string) => { @@ -251,29 +261,36 @@ const SkillRow = memo( gap={6} onDragStart={onDragStart} > - { - e.stopPropagation(); - onToggle(); - }} - > - - + {hasFiles ? ( + { + e.stopPropagation(); + onToggle(); + }} + > + + + ) : ( + + )} {item.name} - {item.fileCount} + {typeof item.fileCount === 'number' && item.fileCount > 0 && ( + {item.fileCount} + )} {expanded && + hasFiles && onOpenFile && tree.map((node) => ( ({ '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 )[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', () => { ))}
); - // 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 }; }) => (
@@ -132,7 +139,7 @@ vi.mock('@/features/SkillsList', () => { {typeof sectionHeader.count === 'number' && {sectionHeader.count}}
)} - {children} + {isEmpty ?
{emptyText}
: children}
); 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(); + // 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(); }); diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx index 8d6db4d1bb..afb35c0101 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx @@ -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(({ 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(({ 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 ( -
- -
- ); - } - 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 ( +
+ +
+ ); } - // Both sections coexist — label each so the source is clear. + const flat = activeCount === 1; + return ( - - - {renderAgentSkillsList()} - - + + {hasAgent && + (flat ? ( + renderAgentSkillsList() + ) : ( + + {renderAgentSkillsList()} + + ))} + {hasProject && ( + + )} + {hasUser && } ); }; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx index 23e5f685bf..aac6672638 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx @@ -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(({ workingDirectory }) => { +const ProjectLevelSkills = memo(({ 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 = ( + { + // 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 ( - { - // 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} ); }); diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/UserLevelSkills.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/UserLevelSkills.tsx new file mode 100644 index 0000000000..be5ea6e029 --- /dev/null +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/UserLevelSkills.tsx @@ -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(({ hideHeader }) => { + const { t } = useTranslation('chat'); + const items = useUserSkills(); + + if (items.length === 0) return null; + + const list = ( + { + startSkillDrag(event, { + category: 'skill', + label: item.name, + type: item.id, + }); + }} + /> + ); + + if (hideHeader) return list; + + return ( + + {list} + + ); +}); + +UserLevelSkills.displayName = 'UserLevelSkills'; + +export default UserLevelSkills; diff --git a/src/server/modules/AgentRuntime/RuntimeExecutors.ts b/src/server/modules/AgentRuntime/RuntimeExecutors.ts index 49c2ed393a..41bfdf356b 100644 --- a/src/server/modules/AgentRuntime/RuntimeExecutors.ts +++ b/src/server/modules/AgentRuntime/RuntimeExecutors.ts @@ -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, diff --git a/src/server/routers/lambda/aiAgent.ts b/src/server/routers/lambda/aiAgent.ts index 732a81f2a7..1deaccd7c0 100644 --- a/src/server/routers/lambda/aiAgent.ts +++ b/src/server/routers/lambda/aiAgent.ts @@ -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 `` 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. diff --git a/src/server/services/agentDocuments/index.test.ts b/src/server/services/agentDocuments/index.test.ts index ad3f567897..c767ffb9f5 100644 --- a/src/server/services/agentDocuments/index.test.ts +++ b/src/server/services/agentDocuments/index.test.ts @@ -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>): 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'); + }); + }); }); diff --git a/src/server/services/aiAgent/index.ts b/src/server/services/aiAgent/index.ts index 185826e9ae..ae04aa0ebb 100644 --- a/src/server/services/aiAgent/index.ts +++ b/src/server/services/aiAgent/index.ts @@ -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(); + 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) { diff --git a/src/server/services/toolExecution/serverRuntimes/skills.ts b/src/server/services/toolExecution/serverRuntimes/skills.ts index 434fcabf9c..a1e7aac225 100644 --- a/src/server/services/toolExecution/serverRuntimes/skills.ts +++ b/src/server/services/toolExecution/serverRuntimes/skills.ts @@ -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 ``). // `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, }); }, diff --git a/src/server/services/toolExecution/types.ts b/src/server/services/toolExecution/types.ts index 339a2b4011..1a96824a5d 100644 --- a/src/server/services/toolExecution/types.ts +++ b/src/server/services/toolExecution/types.ts @@ -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 */ diff --git a/src/services/aiAgent.ts b/src/services/aiAgent.ts index 1b3da706b1..2a5ac3f809 100644 --- a/src/services/aiAgent.ts +++ b/src/services/aiAgent.ts @@ -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 `` + * 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; diff --git a/src/store/chat/slices/aiChat/actions/gateway.ts b/src/store/chat/slices/aiChat/actions/gateway.ts index a7f2ca3b3e..e2bd0e38fa 100644 --- a/src/store/chat/slices/aiChat/actions/gateway.ts +++ b/src/store/chat/slices/aiChat/actions/gateway.ts @@ -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 + * ``. 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; // ─── 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,