feat: skills

This commit is contained in:
Acbox
2026-02-08 01:57:06 +08:00
parent 5b09c53a3b
commit 318bd87f65
12 changed files with 254 additions and 43 deletions
+25 -4
View File
@@ -1,5 +1,5 @@
import { generateText, ImagePart, LanguageModelUsage, ModelMessage, stepCountIs, streamText, UserModelMessage } from 'ai'
import { AgentInput, AgentParams, allActions, HTTPMCPConnection, MCPConnection, Schedule } from './types'
import { AgentInput, AgentParams, AgentSkill, allActions, HTTPMCPConnection, MCPConnection, Schedule } from './types'
import { system, schedule, user, subagentSystem } from './prompts'
import { AuthFetcher } from './index'
import { createModel } from './model'
@@ -22,6 +22,7 @@ export const createAgent = ({
allowedActions = allActions,
channels = [],
mcpConnections = [],
skills = [],
currentChannel = 'Unknown Channel',
identity = {
botId: '',
@@ -33,6 +34,18 @@ export const createAgent = ({
auth,
}: AgentParams, fetch: AuthFetcher) => {
const model = createModel(modelConfig)
const enabledSkills: AgentSkill[] = []
const enableSkill = (skill: string) => {
const agentSkill = skills.find(s => s.name === skill)
if (agentSkill) {
enabledSkills.push(agentSkill)
}
}
const getEnabledSkills = () => {
return enabledSkills.map(skill => skill.name)
}
const getDefaultMCPConnections = (): MCPConnection[] => {
const fs: HTTPMCPConnection = {
@@ -52,8 +65,8 @@ export const createAgent = ({
language,
maxContextLoadTime: activeContextTime,
channels,
skills: [],
enabledSkills: [],
skills,
enabledSkills,
})
}
@@ -63,6 +76,7 @@ export const createAgent = ({
model: modelConfig,
brave,
identity,
enableSkill,
})
const defaultMCPConnections = getDefaultMCPConnections()
const { tools: mcpTools, close: closeMCP } = await getMCPTools([
@@ -99,6 +113,7 @@ export const createAgent = ({
const ask = async (input: AgentInput) => {
const userPrompt = generateUserPrompt(input)
const messages = [...input.messages, userPrompt]
input.skills.forEach(skill => enableSkill(skill))
const systemPrompt = generateSystemPrompt()
const { tools, close } = await getAgentTools()
const { response, reasoning, text, usage } = await generateText({
@@ -125,6 +140,7 @@ export const createAgent = ({
usage,
text: cleanedText,
attachments: allAttachments,
skills: getEnabledSkills(),
}
}
@@ -169,12 +185,14 @@ export const createAgent = ({
reasoning: reasoning.map(part => part.text),
usage,
text,
skills: getEnabledSkills(),
}
}
const triggerSchedule = async (params: {
schedule: Schedule
messages: ModelMessage[]
skills: string[]
}) => {
const scheduleMessage: UserModelMessage = {
role: 'user',
@@ -183,6 +201,7 @@ export const createAgent = ({
]
}
const messages = [...params.messages, scheduleMessage]
params.skills.forEach(skill => enableSkill(skill))
const { tools, close } = await getAgentTools()
const { response, reasoning, text, usage } = await generateText({
model,
@@ -199,12 +218,14 @@ export const createAgent = ({
reasoning: reasoning.map(part => part.text),
usage,
text,
skills: getEnabledSkills(),
}
}
async function* stream(input: AgentInput): AsyncGenerator<AgentAction> {
const userPrompt = generateUserPrompt(input)
const messages = [...input.messages, userPrompt]
input.skills.forEach(skill => enableSkill(skill))
const systemPrompt = generateSystemPrompt()
const attachmentsExtractor = new AttachmentsStreamExtractor()
const result: {
@@ -320,9 +341,9 @@ export const createAgent = ({
yield {
type: 'agent_end',
messages: [userPrompt, ...strippedMessages],
skills: [],
reasoning: result.reasoning,
usage: result.usage!,
skills: getEnabledSkills(),
}
}
+6 -1
View File
@@ -4,7 +4,7 @@ import { createAgent } from '../agent'
import { createAuthFetcher, getBaseUrl, getBraveConfig } from '../index'
import { ModelConfig } from '../types'
import { bearerMiddleware } from '../middlewares/bearer'
import { AllowedActionModel, AttachmentModel, IdentityContextModel, MCPConnectionModel, ModelConfigModel, ScheduleModel } from '../models'
import { AgentSkillModel, AllowedActionModel, AttachmentModel, IdentityContextModel, MCPConnectionModel, ModelConfigModel, ScheduleModel } from '../models'
import { allActions } from '../types'
const AgentModel = z.object({
@@ -14,6 +14,7 @@ const AgentModel = z.object({
currentChannel: z.string(),
allowedActions: z.array(AllowedActionModel).optional().default(allActions),
messages: z.array(z.any()),
usableSkills: z.array(AgentSkillModel).optional().default([]),
skills: z.array(z.string()),
identity: IdentityContextModel,
attachments: z.array(AttachmentModel).optional().default([]),
@@ -37,6 +38,7 @@ export const chatModule = new Elysia({ prefix: '/chat' })
bearer: bearer!,
baseUrl: getBaseUrl(),
},
skills: body.usableSkills,
brave: getBraveConfig(),
}, authFetcher)
return ask({
@@ -66,6 +68,7 @@ export const chatModule = new Elysia({ prefix: '/chat' })
bearer: bearer!,
baseUrl: getBaseUrl(),
},
skills: body.usableSkills,
brave: getBraveConfig(),
}, authFetcher)
for await (const action of stream({
@@ -101,11 +104,13 @@ export const chatModule = new Elysia({ prefix: '/chat' })
bearer: bearer!,
baseUrl: getBaseUrl(),
},
skills: body.usableSkills,
brave: getBraveConfig(),
}, authFetcher)
return triggerSchedule({
schedule: body.schedule,
messages: body.messages,
skills: body.skills,
})
}, {
body: AgentModel.extend({
+2
View File
@@ -35,6 +35,8 @@ export const system = ({
'time-now': date.toISOString(),
}
console.log('enabledSkills', enabledSkills)
return `
---
${Bun.YAML.stringify(headers)}
+8 -2
View File
@@ -7,17 +7,19 @@ import { getMemoryTools } from './memory'
import { getSubagentTools } from './subagent'
import { getContactTools } from './contact'
import { getMessageTools } from './message'
import { getSkillTools } from './skill'
export interface ToolsParams {
fetch: AuthFetcher
model: ModelConfig
brave?: BraveConfig
identity: IdentityContext
enableSkill: (skill: string) => void
}
export const getTools = (
actions: AgentAction[],
{ fetch, model, brave, identity }: ToolsParams
{ fetch, model, brave, identity, enableSkill }: ToolsParams
) => {
const tools: ToolSet = {}
if (actions.includes(AgentAction.Web) && brave) {
@@ -44,5 +46,9 @@ export const getTools = (
const messageTools = getMessageTools({ fetch, identity })
Object.assign(tools, messageTools)
}
return tools
if (actions.includes(AgentAction.Skill)) {
const skillTools = getSkillTools({ useSkill: enableSkill })
Object.assign(tools, skillTools)
}
return tools
}
+3 -9
View File
@@ -1,13 +1,11 @@
import { AgentSkill } from '../types'
import { tool } from 'ai'
import { z } from 'zod'
interface SkillToolParams {
skills: AgentSkill[]
useSkill: (skill: AgentSkill, reason: string) => void
useSkill: (skill: string) => void
}
export const getSkillTools = ({ skills, useSkill }: SkillToolParams) => {
export const getSkillTools = ({ useSkill }: SkillToolParams) => {
const useSkillTool = tool({
description: 'Use a skill if you think it is relevant to the current task',
inputSchema: z.object({
@@ -15,11 +13,7 @@ export const getSkillTools = ({ skills, useSkill }: SkillToolParams) => {
reason: z.string().describe('The reason why you think this skill is relevant to the current task'),
}),
execute: async ({ skillName, reason }) => {
const skill = skills.find((s) => s.name === skillName)
if (!skill) {
return { error: 'Skill not found' }
}
await useSkill(skill, reason)
useSkill(skillName)
return {
success: true,
skillName,
+2 -2
View File
@@ -51,6 +51,7 @@ export interface AgentParams {
mcpConnections?: MCPConnection[]
identity?: IdentityContext
auth: AgentAuthContext
skills?: AgentSkill[]
}
export interface AgentInput {
@@ -64,6 +65,5 @@ export interface AgentSkill {
name: string
description: string
content: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: Record<string, any>
metadata?: Record<string, unknown>
}
+23
View File
@@ -156,6 +156,7 @@ func main() {
preauthHandler := handlers.NewPreauthHandler(preauthService, botService, usersService)
chatResolver = chat.NewResolver(logger.L, modelsService, queries, memoryService, historyService, settingsService, cfg.AgentGateway.BaseURL(), 120*time.Second)
chatResolver.SetSkillLoader(&skillLoaderAdapter{handler: containerdHandler})
embeddingsHandler := handlers.NewEmbeddingsHandler(logger.L, modelsService, queries)
swaggerHandler := handlers.NewSwaggerHandler(logger.L)
chatHandler := handlers.NewChatHandler(logger.L, chatResolver, botService, usersService)
@@ -338,3 +339,25 @@ func (c *lazyLLMClient) resolve(ctx context.Context) (memory.LLM, error) {
}
return memory.NewLLMClient(c.logger, memoryProvider.BaseUrl, memoryProvider.ApiKey, memoryModel.ModelID, c.timeout)
}
// skillLoaderAdapter bridges handlers.ContainerdHandler to chat.SkillLoader.
type skillLoaderAdapter struct {
handler *handlers.ContainerdHandler
}
func (a *skillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]chat.SkillEntry, error) {
items, err := a.handler.LoadSkills(ctx, botID)
if err != nil {
return nil, err
}
entries := make([]chat.SkillEntry, len(items))
for i, item := range items {
entries[i] = chat.SkillEntry{
Name: item.Name,
Description: item.Description,
Content: item.Content,
Metadata: item.Metadata,
}
}
return entries, nil
}
+4
View File
@@ -4942,6 +4942,10 @@ const docTemplate = `{
"description": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"name": {
"type": "string"
}
+4
View File
@@ -4933,6 +4933,10 @@
"description": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"name": {
"type": "string"
}
+3
View File
@@ -654,6 +654,9 @@ definitions:
type: string
description:
type: string
metadata:
additionalProperties: {}
type: object
name:
type: string
type: object
+51
View File
@@ -24,6 +24,19 @@ import (
const defaultMaxContextMinutes = 24 * 60
// SkillEntry represents a skill loaded from the container.
type SkillEntry struct {
Name string
Description string
Content string
Metadata map[string]any
}
// SkillLoader loads skills for a given bot from its container.
type SkillLoader interface {
LoadSkills(ctx context.Context, botID string) ([]SkillEntry, error)
}
// Resolver orchestrates chat with the agent gateway.
type Resolver struct {
modelsService *models.Service
@@ -31,6 +44,7 @@ type Resolver struct {
memoryService *memory.Service
historyService *history.Service
settingsService *settings.Service
skillLoader SkillLoader
gatewayBaseURL string
timeout time.Duration
logger *slog.Logger
@@ -70,6 +84,11 @@ func NewResolver(
}
}
// SetSkillLoader sets the skill loader used to populate usable skills in gateway requests.
func (r *Resolver) SetSkillLoader(sl SkillLoader) {
r.skillLoader = sl
}
// --- gateway payload ---
type gatewayModelConfig struct {
@@ -93,6 +112,13 @@ type gatewayIdentity struct {
SessionToken string `json:"sessionToken,omitempty"`
}
type gatewaySkill struct {
Name string `json:"name"`
Description string `json:"description"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type gatewayRequest struct {
Model gatewayModelConfig `json:"model"`
ActiveContextTime int `json:"activeContextTime"`
@@ -101,6 +127,7 @@ type gatewayRequest struct {
AllowedActions []string `json:"allowedActions,omitempty"`
Messages []ModelMessage `json:"messages"`
Skills []string `json:"skills"`
UsableSkills []gatewaySkill `json:"usableSkills"`
Query string `json:"query"`
Identity gatewayIdentity `json:"identity"`
Attachments []any `json:"attachments"`
@@ -130,6 +157,7 @@ type triggerScheduleRequest struct {
AllowedActions []string `json:"allowedActions,omitempty"`
Messages []ModelMessage `json:"messages"`
Skills []string `json:"skills"`
UsableSkills []gatewaySkill `json:"usableSkills"`
Identity gatewayIdentity `json:"identity"`
Attachments []any `json:"attachments"`
Schedule gatewaySchedule `json:"schedule"`
@@ -191,6 +219,27 @@ func (r *Resolver) resolve(ctx context.Context, req ChatRequest) (resolvedContex
skills := dedup(append(historySkills, req.Skills...))
containerID := r.resolveContainerID(ctx, req.BotID, req.ContainerID)
var usableSkills []gatewaySkill
if r.skillLoader != nil {
entries, err := r.skillLoader.LoadSkills(ctx, req.BotID)
if err != nil {
r.logger.Warn("failed to load usable skills", slog.String("bot_id", req.BotID), slog.Any("error", err))
} else {
usableSkills = make([]gatewaySkill, 0, len(entries))
for _, e := range entries {
usableSkills = append(usableSkills, gatewaySkill{
Name: e.Name,
Description: e.Description,
Content: e.Content,
Metadata: e.Metadata,
})
}
}
}
if usableSkills == nil {
usableSkills = []gatewaySkill{}
}
payload := gatewayRequest{
Model: gatewayModelConfig{
ModelID: chatModel.ModelID,
@@ -205,6 +254,7 @@ func (r *Resolver) resolve(ctx context.Context, req ChatRequest) (resolvedContex
AllowedActions: req.AllowedActions,
Messages: nonNilMessages(messages),
Skills: nonNilStrings(skills),
UsableSkills: usableSkills,
Query: req.Query,
Identity: gatewayIdentity{
BotID: req.BotID,
@@ -279,6 +329,7 @@ func (r *Resolver) TriggerSchedule(ctx context.Context, botID string, payload sc
AllowedActions: rc.payload.AllowedActions,
Messages: rc.payload.Messages,
Skills: rc.payload.Skills,
UsableSkills: rc.payload.UsableSkills,
Identity: gatewayIdentity{
BotID: rc.payload.Identity.BotID,
SessionID: rc.payload.Identity.SessionID,
+123 -25
View File
@@ -11,15 +11,17 @@ import (
"time"
"github.com/labstack/echo/v4"
"gopkg.in/yaml.v3"
"github.com/memohai/memoh/internal/config"
mcptools "github.com/memohai/memoh/internal/mcp"
)
type SkillItem struct {
Name string `json:"name"`
Description string `json:"description"`
Content string `json:"content"`
Name string `json:"name"`
Description string `json:"description"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type SkillsResponse struct {
@@ -64,7 +66,7 @@ func (h *ContainerdHandler) ListSkills(c echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
listPayload, err := h.callMCPTool(ctx, containerID, "fs.list", map[string]any{
listPayload, err := h.callMCPTool(ctx, containerID, "list", map[string]any{
"path": ".skills",
"recursive": false,
})
@@ -82,14 +84,16 @@ func (h *ContainerdHandler) ListSkills(c echo.Context) error {
if skillPath == "" {
continue
}
content, err := h.readSkillFile(ctx, containerID, skillPath)
raw, err := h.readSkillFile(ctx, containerID, skillPath)
if err != nil {
continue
}
parsed := parseSkillFile(raw, name)
skills = append(skills, SkillItem{
Name: name,
Description: skillDescription(content),
Content: content,
Name: parsed.Name,
Description: parsed.Description,
Content: parsed.Content,
Metadata: parsed.Metadata,
})
}
@@ -137,7 +141,7 @@ func (h *ContainerdHandler) UpsertSkills(c echo.Context) error {
content = buildSkillContent(name, strings.TrimSpace(skill.Description))
}
filePath := path.Join(".skills", name, "SKILL.md")
if _, err := h.callMCPTool(ctx, containerID, "fs.write", map[string]any{
if _, err := h.callMCPTool(ctx, containerID, "write", map[string]any{
"path": filePath,
"content": content,
}); err != nil {
@@ -186,7 +190,7 @@ func (h *ContainerdHandler) DeleteSkills(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name")
}
deletePath := path.Join(".skills", skillName)
if _, err := h.callMCPTool(ctx, containerID, "fs.delete", map[string]any{
if _, err := h.callMCPTool(ctx, containerID, "delete", map[string]any{
"path": deletePath,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
@@ -196,6 +200,53 @@ func (h *ContainerdHandler) DeleteSkills(c echo.Context) error {
return c.JSON(http.StatusOK, skillsOpResponse{OK: true})
}
// LoadSkills loads all skills from the container for the given bot.
// This implements chat.SkillLoader.
func (h *ContainerdHandler) LoadSkills(ctx context.Context, botID string) ([]SkillItem, error) {
containerID, err := h.botContainerID(ctx, botID)
if err != nil {
return nil, err
}
if err := h.ensureTaskRunning(ctx, containerID); err != nil {
return nil, err
}
if err := h.ensureSkillsDirHost(botID); err != nil {
return nil, err
}
listPayload, err := h.callMCPTool(ctx, containerID, "list", map[string]any{
"path": ".skills",
"recursive": false,
})
if err != nil {
return nil, err
}
entries, err := extractListEntries(listPayload)
if err != nil {
return nil, err
}
skills := make([]SkillItem, 0, len(entries))
for _, entry := range entries {
skillPath, name := skillPathForEntry(entry)
if skillPath == "" {
continue
}
raw, err := h.readSkillFile(ctx, containerID, skillPath)
if err != nil {
continue
}
parsed := parseSkillFile(raw, name)
skills = append(skills, SkillItem{
Name: parsed.Name,
Description: parsed.Description,
Content: parsed.Content,
Metadata: parsed.Metadata,
})
}
return skills, nil
}
func (h *ContainerdHandler) ensureSkillsDirHost(botID string) error {
dataRoot := strings.TrimSpace(h.cfg.DataRoot)
if dataRoot == "" {
@@ -206,7 +257,7 @@ func (h *ContainerdHandler) ensureSkillsDirHost(botID string) error {
}
func (h *ContainerdHandler) readSkillFile(ctx context.Context, containerID, filePath string) (string, error) {
payload, err := h.callMCPTool(ctx, containerID, "fs.read", map[string]any{
payload, err := h.callMCPTool(ctx, containerID, "read", map[string]any{
"path": filePath,
})
if err != nil {
@@ -309,26 +360,73 @@ func skillPathForEntry(entry skillEntry) (string, string) {
return "", ""
}
func skillDescription(content string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
return strings.TrimSpace(strings.TrimPrefix(line, "#"))
}
return line
// parsedSkill holds the result of parsing a SKILL.md file with YAML frontmatter.
type parsedSkill struct {
Name string
Description string
Content string // body after frontmatter
Metadata map[string]any // "metadata" key from frontmatter
}
// parseSkillFile parses a SKILL.md file with YAML frontmatter delimited by "---".
// Format:
//
// ---
// name: your-skill-name
// description: Brief description
// metadata:
// key: value
// ---
// # Body content ...
func parseSkillFile(raw string, fallbackName string) parsedSkill {
result := parsedSkill{Name: fallbackName}
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(trimmed, "---") {
return result
}
return ""
// Find closing "---".
rest := trimmed[3:]
rest = strings.TrimLeft(rest, " \t")
if len(rest) > 0 && rest[0] == '\n' {
rest = rest[1:]
} else if len(rest) > 1 && rest[0] == '\r' && rest[1] == '\n' {
rest = rest[2:]
}
closingIdx := strings.Index(rest, "\n---")
if closingIdx < 0 {
return result
}
frontmatterRaw := rest[:closingIdx]
body := rest[closingIdx+4:]
body = strings.TrimLeft(body, "\r\n")
result.Content = body
var fm struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Metadata map[string]any `yaml:"metadata"`
}
if err := yaml.Unmarshal([]byte(frontmatterRaw), &fm); err != nil {
return result
}
if strings.TrimSpace(fm.Name) != "" {
result.Name = strings.TrimSpace(fm.Name)
}
result.Description = strings.TrimSpace(fm.Description)
result.Metadata = fm.Metadata
return result
}
func buildSkillContent(name, description string) string {
if description == "" {
return "# " + name
description = name
}
return "# " + name + "\n\n" + description
return "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name + "\n\n" + description
}
func isValidSkillName(name string) bool {