Implement Skills feature: filesystem-based skills with agent assignment

- Add SkillService: scans data/skills/ directory, parses _meta.json and SKILL.md frontmatter
- Add SkillEndpoint: REST API for listing skills and managing agent-skill assignments
- Add agent_skills DB table for agent-to-skill mapping (only relation in DB, skills live on filesystem)
- Extend SystemPromptBuilder: inject assigned skills' SKILL.md content into system prompt
- Extend ChatEndpoint + SessionEndpoint: load agent skills and pass to prompt builder
- Frontend: skill checkboxes in agent form, API functions for skill management
- Add form.css styling for skill list items
This commit is contained in:
Meleki committed 2026-03-01 17:22:14 +01:00
1 parent b18dc78651
commit cf6caea2dc
11 files changed
+454 -6

No files matched your search

+58
View File
@@ -134,6 +134,17 @@ public class DatabaseService
TryAddColumn(conn, "agents", "use_identity", "INTEGER NOT NULL DEFAULT 1");
TryAddColumn(conn, "agents", "use_user_info", "INTEGER NOT NULL DEFAULT 1");
TryAddColumn(conn, "agents", "use_memory", "INTEGER NOT NULL DEFAULT 1");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
skill_slug TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(agent_id, skill_slug)
);
CREATE INDEX IF NOT EXISTS idx_agent_skills_agent ON agent_skills(agent_id);
");
}
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
@@ -570,4 +581,51 @@ public class DatabaseService
ORDER BY m.id",
new { sessionId });
}
// ── Agent Skills ──
public async Task<IEnumerable<string>> GetAgentSkills(string agentId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<string>(
"SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug",
new { agentId });
}
public async Task SetAgentSkills(string agentId, IEnumerable<string> skillSlugs)
{
using var conn = CreateConnection();
using var tx = conn.BeginTransaction();
await conn.ExecuteAsync("DELETE FROM agent_skills WHERE agent_id = @agentId", new { agentId }, tx);
var now = DateTime.UtcNow.ToString("o");
foreach (var slug in skillSlugs)
{
await conn.ExecuteAsync(@"
INSERT INTO agent_skills (agent_id, skill_slug, created_at)
VALUES (@agentId, @slug, @now)",
new { agentId, slug, now }, tx);
}
tx.Commit();
}
public async Task AddAgentSkill(string agentId, string skillSlug)
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
await conn.ExecuteAsync(@"
INSERT OR IGNORE INTO agent_skills (agent_id, skill_slug, created_at)
VALUES (@agentId, @skillSlug, @now)",
new { agentId, skillSlug, now });
}
public async Task RemoveAgentSkill(string agentId, string skillSlug)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"DELETE FROM agent_skills WHERE agent_id = @agentId AND skill_slug = @skillSlug",
new { agentId, skillSlug });
}
}
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace Gott.Services;
public class SkillService
{
private readonly string _skillsDir;
public string SkillsDir => _skillsDir;
public SkillService(string dataDir)
{
_skillsDir = Path.Combine(dataDir, "skills");
Directory.CreateDirectory(_skillsDir);
}
/// <summary>
/// Scannt den Skills-Ordner und gibt alle verfügbaren Skills zurück.
/// </summary>
public List<SkillInfo> GetAllSkills()
{
var skills = new List<SkillInfo>();
if (!Directory.Exists(_skillsDir)) return skills;
foreach (var dir in Directory.GetDirectories(_skillsDir))
{
var skill = LoadSkill(dir);
if (skill != null) skills.Add(skill);
}
return skills.OrderBy(s => s.Name).ToList();
}
/// <summary>
/// Lädt einen einzelnen Skill anhand seines Slugs.
/// </summary>
public SkillInfo GetSkill(string slug)
{
var dir = Path.Combine(_skillsDir, slug);
if (!Directory.Exists(dir)) return null;
return LoadSkill(dir);
}
/// <summary>
/// Liest die SKILL.md eines Skills. Gibt null zurück wenn nicht vorhanden.
/// </summary>
public string GetSkillPrompt(string slug)
{
var skillMd = Path.Combine(_skillsDir, slug, "SKILL.md");
if (!File.Exists(skillMd)) return null;
return File.ReadAllText(skillMd);
}
private SkillInfo LoadSkill(string dir)
{
var slug = Path.GetFileName(dir);
var metaFile = Path.Combine(dir, "_meta.json");
var skillMd = Path.Combine(dir, "SKILL.md");
var info = new SkillInfo
{
Slug = slug,
Path = Path.GetFullPath(dir),
HasSkillMd = File.Exists(skillMd)
};
// Name/Description aus SKILL.md Frontmatter lesen
if (File.Exists(skillMd))
{
var content = File.ReadAllText(skillMd);
var frontmatter = ParseFrontmatter(content);
if (frontmatter.TryGetValue("name", out var name)) info.Name = name;
if (frontmatter.TryGetValue("description", out var desc)) info.Description = desc;
if (frontmatter.TryGetValue("version", out var ver)) info.Version = ver;
}
// _meta.json als Fallback/Ergänzung
if (File.Exists(metaFile))
{
try
{
var json = File.ReadAllText(metaFile);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (string.IsNullOrEmpty(info.Name) && root.TryGetProperty("name", out var n))
info.Name = n.GetString();
if (string.IsNullOrEmpty(info.Version) && root.TryGetProperty("version", out var v))
info.Version = v.GetString();
if (root.TryGetProperty("slug", out var s) && !string.IsNullOrEmpty(s.GetString()))
info.Slug = s.GetString();
}
catch { /* Fehler in _meta.json ignorieren */ }
}
// Fallback: Name = Slug
if (string.IsNullOrEmpty(info.Name)) info.Name = slug;
return info;
}
/// <summary>
/// Parst einen einfachen YAML-Frontmatter-Block (--- ... ---) aus einer Markdown-Datei.
/// </summary>
private Dictionary<string, string> ParseFrontmatter(string content)
{
var result = new Dictionary<string, string>();
if (!content.StartsWith("---")) return result;
var end = content.IndexOf("---", 3, StringComparison.Ordinal);
if (end < 0) return result;
var frontmatter = content.Substring(3, end - 3).Trim();
foreach (var line in frontmatter.Split('\n'))
{
var colon = line.IndexOf(':');
if (colon < 0) continue;
var key = line.Substring(0, colon).Trim();
var value = line.Substring(colon + 1).Trim();
result[key] = value;
}
return result;
}
}
public class SkillInfo
{
public string Slug { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string Version { get; set; }
public string Path { get; set; }
public bool HasSkillMd { get; set; }
}
+25 -1
View File
@@ -6,7 +6,7 @@ namespace Gott.Services;
public static class SystemPromptBuilder
{
public static string Build(AgentRecord agent, SessionRecord session, IEnumerable<MessageRecord> markedMessages = null, MemoryService memoryService = null, string agentMemoryDir = null, string dataDir = null)
public static string Build(AgentRecord agent, SessionRecord session, IEnumerable<MessageRecord> markedMessages = null, MemoryService memoryService = null, string agentMemoryDir = null, string dataDir = null, IEnumerable<string> skillSlugs = null, SkillService skillService = null)
{
var parts = new List<string>();
@@ -69,6 +69,30 @@ Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: fo
if (!string.IsNullOrWhiteSpace(session.Context))
parts.Add($"## Kontext\n{session.Context}");
// Skills
if (skillSlugs != null && skillService != null)
{
var skillParts = new List<string>();
foreach (var slug in skillSlugs)
{
var skillPrompt = skillService.GetSkillPrompt(slug);
if (string.IsNullOrEmpty(skillPrompt)) continue;
var skill = skillService.GetSkill(slug);
var header = skill != null ? $"### {skill.Name}" : $"### {slug}";
if (skill?.Version != null) header += $" (v{skill.Version})";
header += $"\nPfad: `{skill?.Path ?? slug}`";
skillParts.Add($"{header}\n\n{skillPrompt}");
}
if (skillParts.Count > 0)
{
var skillSection = "## Skills\nDu hast folgende Skills zur Verfügung. Nutze sie über das `execute`-Tool wenn der User etwas anfragt, das ein Skill abdeckt.\n\n"
+ string.Join("\n\n---\n\n", skillParts);
parts.Add(skillSection);
}
}
// Memory-System
if (agent.UseMemory && memoryService != null && !string.IsNullOrWhiteSpace(agent.AgentId))
{