summaryrefslogtreecommitdiff
path: root/src/Agent.App
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 00:04:36 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 00:04:36 +0900
commitd5b3cb20c6dacd31bb54c52a30a0f64487e30215 (patch)
tree1461ed9f6c3ad42488485045f7295f391928e7b7 /src/Agent.App
parente8b625e22f343d75e1b50d1634b4ab06899d060b (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')
-rw-r--r--src/Agent.App/Configuration/SkillLoader.cs105
-rw-r--r--src/Agent.App/Program.cs4
2 files changed, 97 insertions, 12 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);
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 82a3f36..7839e82 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -85,7 +85,7 @@ internal static class Program
{
string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
- string[] skills = SkillLoader.Load(skillDirectory);
+ var skills = SkillLoader.Load(skillDirectory);
var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None,
@@ -96,7 +96,7 @@ internal static class Program
{
string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
- string[] skills = SkillLoader.Load(skillDirectory);
+ var skills = SkillLoader.Load(skillDirectory);
var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits,