diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-11 00:04:36 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-11 00:04:36 +0900 |
| commit | d5b3cb20c6dacd31bb54c52a30a0f64487e30215 (patch) | |
| tree | 1461ed9f6c3ad42488485045f7295f391928e7b7 /src/Agent.App/Configuration | |
| parent | e8b625e22f343d75e1b50d1634b4ab06899d060b (diff) | |
feat: P1 tool improvements and P5 Agent Skills standard compliance
P1: Tool小改善
1. tailバイナリ判定 (TextFileDetector):
- file_search tailモードでtext/binary判定を追加
- 先頭4KBのみ読み、BOM確認(UTF-8/UTF-16 LE/BE)→UTF-8 strict byte検証
- NULバイトのみ判定不可(UTF-16でNULが正常出現するため)
- binaryファイルはToolValidationExceptionで拒否
- TextFileDetectorクラスへ判定ロジックを分離
2. file_edit rename機能:
- file_edit.operation=renameを追加(新規toolではない)
- new_path property追加、Write capability要求(source/destination双方)
- destination既存時は禁止(overwrite不可)
- cross-volume renameは不支持、Tool failureとして拒否
- FileRenameTransaction + RecoveryManager file.rename recovery追加
- crash recovery: destination hash == journal記録の場合のみ戻す
P5: Agent Skills open standard準拠
3. Skill形式をagentskills.io標準へ移行:
- legacy単一Markdown形式(skills/*.md)を廃止
- <skill-name>/SKILL.md標準directory形式のみ対応
- YAML frontmatter必須(name/description)、YamlDotNetで解析
- validation: name必須(1-64文字,lowercase英数字+hyphen,親dir名一致),
description必須(1-1024文字), malformed YAML reject, duplicate fatal
4. Progressive Disclosure:
- Stage 1 Discovery: name+descriptionのみをsystem messageへ(name+desc一覧)
- Stage 3 Instructions: 選択Skillの本文のみ注入
- PromptComposer: 全文埋め込みを廃止、カタログ形式へ変更
- PromptContext: Skills→AvailableSkills+SelectedSkillsへ分割
- 未選択Skill本文はpromptへ入らない
5. Internal Tool境界維持:
- skill_read等のInternal Tool追加なし
- Internal Toolは6群(file_search/file_read/file_edit/process_run/task_update/dir)のまま
- Skill選択・本文ロードはAgent Runtime/Prompt Context Managementの責務
6. AgentSkill data model (Agent.Core/Skills/AgentSkill.cs):
- RootPath, Name, Description, Instructions, License?, Compatibility?, Metadata?, AllowedTools?
- SkillLoader(Agent.App)がYamlDotNetでfrontmatter解析→AgentSkill構築
設計判断:
- renameはfile_edit operation拡張(新規toolではない)
- rmdir空dirのみ制約は維持
- Skill load != permission grant維持
- allowed-toolsはSandbox permissionとして解釈しない
- YamlDotNetはAgent.App側のみ使用(Agent.Coreへ依存追加なし)
変更対象:
- TextFileDetector.cs: 新規、text/binary判定
- WindowsToolBackend.cs: tail binary判定統合、rename case追加
- InternalToolCatalog.cs: file_edit operation enum に rename 追加、new_path property
- InternalToolValidator.cs: rename時new_path必須検証
- TransactionalFileEditor.cs: Rename() + FileRenameTransaction
- RecoveryManager.cs: file.rename recovery
- AgentSkill.cs: 新規data model
- SkillLoader.cs: 全面書き換え、YamlDotNet frontmatter解析、validation
- PromptComposer.cs: Progressive Disclosure、AvailableSkills+SelectedSkills
- AgentRuntimeSettings.cs: Skills型変更
- AgentRuntime.cs: SelectedSkills=[]で初期化
- Program.cs: SkillLoader戻り値型対応
- Specifications.md: FS-07 rename、Skill節Agent Skills standard準拠
- Design.md: §7.1 tail binary判定、§7.3 rename、AgentSkill model
- ImplementationPlan.md: Prompt順序更新、Skill標準準拠
テスト:
- SecurityPolicyTests.cs: tail binary拒否/UTF-8/UTF-16 text許可、rename正常/dest既存拒否/許可外拒否
- InternalToolTests.cs: rename validationテスト追加
- ConfigurationTests.cs: SkillLoader標準形式/missing frontmatter/name mismatch/duplicate tests
- PromptAndRuntimeTests.cs: PromptContext型変更対応、skill catalog検証
検証結果:
- Release build: 警告0、エラー0
- Core tests: 61/61 PASS、skip 0
- Planner tests: 10/10 PASS、skip 0
- Internal Tool 6群固定: 確認済み
Diffstat (limited to 'src/Agent.App/Configuration')
| -rw-r--r-- | src/Agent.App/Configuration/SkillLoader.cs | 105 |
1 files changed, 95 insertions, 10 deletions
diff --git a/src/Agent.App/Configuration/SkillLoader.cs b/src/Agent.App/Configuration/SkillLoader.cs index 6923c12..bac1559 100644 --- a/src/Agent.App/Configuration/SkillLoader.cs +++ b/src/Agent.App/Configuration/SkillLoader.cs @@ -1,24 +1,109 @@ +using System.Text.RegularExpressions; +using Agent.Core.Skills; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + namespace Agent.App.Configuration; public static class SkillLoader { - public static string[] Load(string directory) + private static readonly Regex NamePattern = new("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", RegexOptions.Compiled); + + public static IReadOnlyList<AgentSkill> Load(string directory) { - var paths = new List<string>(); - paths.AddRange(Directory.EnumerateFiles(directory, "*.md", SearchOption.TopDirectoryOnly)); + if (!Directory.Exists(directory)) return []; + + var skills = new List<AgentSkill>(); + var names = new HashSet<string>(StringComparer.Ordinal); foreach (string child in Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly)) { var info = new DirectoryInfo(child); if ((info.Attributes & FileAttributes.ReparsePoint) != 0) continue; - string standardSkill = Path.Combine(info.FullName, "SKILL.md"); - if (File.Exists(standardSkill)) paths.Add(standardSkill); + + string skillMd = Path.Combine(info.FullName, "SKILL.md"); + if (!File.Exists(skillMd)) continue; + + string dirName = info.Name; + string content = File.ReadAllText(skillMd); + var (frontmatter, body) = SplitFrontmatter(content); + + if (frontmatter is null) + throw new SkillLoadException($"Skill '{dirName}': SKILL.md is missing YAML frontmatter."); + + SkillFrontmatter fm = ParseFrontmatter(frontmatter); + + ValidateSkill(dirName, fm, names); + + skills.Add(new AgentSkill( + RootPath: info.FullName, + Name: fm.Name, + Description: fm.Description, + Instructions: body.Trim(), + License: fm.License, + Compatibility: fm.Compatibility, + Metadata: fm.Metadata, + AllowedTools: fm.AllowedTools)); + } + + return skills; + } + + private static void ValidateSkill(string dirName, SkillFrontmatter fm, HashSet<string> names) + { + if (string.IsNullOrWhiteSpace(fm.Name)) + throw new SkillLoadException($"Skill '{dirName}': name is required."); + if (fm.Name.Length < 1 || fm.Name.Length > 64) + throw new SkillLoadException($"Skill '{dirName}': name must be 1-64 characters."); + if (!NamePattern.IsMatch(fm.Name)) + throw new SkillLoadException($"Skill '{dirName}': name must be lowercase alphanumeric with hyphens."); + if (!fm.Name.Equals(dirName, StringComparison.Ordinal)) + throw new SkillLoadException($"Skill '{dirName}': name '{fm.Name}' does not match directory name."); + if (string.IsNullOrWhiteSpace(fm.Description)) + throw new SkillLoadException($"Skill '{dirName}': description is required."); + if (fm.Description.Length > 1024) + throw new SkillLoadException($"Skill '{dirName}': description must be 1-1024 characters."); + if (!names.Add(fm.Name)) + throw new SkillLoadException($"Duplicate skill name '{fm.Name}'."); + } + + private static (string? Frontmatter, string Body) SplitFrontmatter(string content) + { + if (!content.StartsWith("---", StringComparison.Ordinal)) return (null, content); + int end = content.IndexOf("\n---", 3, StringComparison.Ordinal); + if (end < 0) return (null, content); + string frontmatter = content[3..end].Trim(); + int bodyStart = end + 4; + if (bodyStart < content.Length && content[bodyStart] == '\r') bodyStart++; + if (bodyStart < content.Length && content[bodyStart] == '\n') bodyStart++; + string body = bodyStart < content.Length ? content[bodyStart..] : string.Empty; + return (frontmatter, body); + } + + private static SkillFrontmatter ParseFrontmatter(string yaml) + { + try + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + return deserializer.Deserialize<SkillFrontmatter>(yaml); + } + catch (Exception ex) + { + throw new SkillLoadException($"Malformed YAML frontmatter: {ex.Message}"); } + } - return paths - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order(StringComparer.OrdinalIgnoreCase) - .Select(File.ReadAllText) - .ToArray(); + private sealed class SkillFrontmatter + { + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public string? License { get; set; } + public string? Compatibility { get; set; } + public Dictionary<string, string>? Metadata { get; set; } + public string? AllowedTools { get; set; } } } + +public sealed class SkillLoadException(string message) : Exception(message); |
