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 /tests/Agent.Core.Tests | |
| 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 'tests/Agent.Core.Tests')
| -rw-r--r-- | tests/Agent.Core.Tests/ConfigurationTests.cs | 45 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/InternalToolTests.cs | 2 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | 11 |
3 files changed, 52 insertions, 6 deletions
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs index 2c52b0c..79c746d 100644 --- a/tests/Agent.Core.Tests/ConfigurationTests.cs +++ b/tests/Agent.Core.Tests/ConfigurationTests.cs @@ -1,5 +1,6 @@ using Agent.App.Cli; using Agent.App.Configuration; +using Agent.Core.Skills; namespace Agent.Core.Tests; @@ -43,16 +44,52 @@ public sealed class ConfigurationTests } [Fact] - public void SkillLoader_supports_legacy_markdown_and_standard_skill_directories() + public void SkillLoader_loads_standard_skill_directories_only() { using var directory = new TestDirectory(); + // Legacy single markdown — should be ignored (not loaded). directory.File("skills/legacy.md", "legacy skill"); - directory.File("skills/python-via-uv/SKILL.md", "standard skill"); + // Standard skill directory with frontmatter. + directory.File("skills/python-via-uv/SKILL.md", "---\nname: python-via-uv\ndescription: Python execution skill.\n---\n# Python via uv\nInstructions here."); + // Non-SKILL.md files in skill directory should not be loaded as skills. directory.File("skills/python-via-uv/reference.md", "must not be loaded as a skill"); - string[] skills = SkillLoader.Load(Path.Combine(directory.Path, "skills")); + var skills = SkillLoader.Load(Path.Combine(directory.Path, "skills")); - Assert.Equal(["legacy skill", "standard skill"], skills); + Assert.Single(skills); + Assert.Equal("python-via-uv", skills[0].Name); + Assert.Equal("Python execution skill.", skills[0].Description); + Assert.Contains("Instructions here.", skills[0].Instructions); + } + + [Fact] + public void SkillLoader_rejects_missing_frontmatter() + { + using var directory = new TestDirectory(); + directory.File("skills/no-frontmatter/SKILL.md", "# No frontmatter\nJust body."); + + Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills"))); + } + + [Fact] + public void SkillLoader_rejects_name_mismatch() + { + using var directory = new TestDirectory(); + directory.File("skills/wrong-name/SKILL.md", "---\nname: different-name\ndescription: A skill.\n---\nBody."); + + Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills"))); + } + + [Fact] + public void SkillLoader_rejects_duplicate_names() + { + using var directory = new TestDirectory(); + directory.File("skills/dup-skill/SKILL.md", "---\nname: dup-skill\ndescription: First.\n---\nBody1."); + // Create a second directory with the same skill name (via symlink or junction is not needed; + // just create another dir that also has name: dup-skill in frontmatter). + directory.File("skills/dup-skill-copy/SKILL.md", "---\nname: dup-skill\ndescription: Second.\n---\nBody2."); + + Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills"))); } private static string CreateValidConfiguration(TestDirectory directory) diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs index 2015702..c157458 100644 --- a/tests/Agent.Core.Tests/InternalToolTests.cs +++ b/tests/Agent.Core.Tests/InternalToolTests.cs @@ -19,6 +19,8 @@ public sealed class InternalToolTests [InlineData("file_search", "{\"root\":\".\",\"mode\":\"text\",\"query\":\"x\",\"max_results\":1001}")] [InlineData("process_run", "{\"executable\":\"x\",\"args\":\"bad\",\"cwd\":\".\"}")] [InlineData("task_update", "{\"operation\":\"add\",\"description\":\"missing estimate\"}")] + [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\"}")] + [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\",\"new_path\":\"\"}")] public void Malformed_arguments_are_rejected(string tool, string json) { using JsonDocument document = JsonDocument.Parse(json); diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs index 5e682cd..27308ef 100644 --- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs +++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs @@ -2,6 +2,7 @@ using Agent.Core.Ollama; using Agent.Core.Prompts; using Agent.Core.Runtime; using Agent.Core.Sessions; +using Agent.Core.Skills; using Agent.Core.Tasks; using Agent.Core.Tools; using System.Text.Json; @@ -13,12 +14,18 @@ public sealed class PromptAndRuntimeTests [Fact] public void Prompt_order_is_fixed_and_request_is_last() { + var availableSkills = new List<AgentSkill> + { + new("root", "test-skill", "A test skill.", "skill instructions") + }; IReadOnlyList<OllamaMessage> messages = PromptComposer.Compose(new PromptContext( - "fixed", "user system", "ja", ["skill"], "capability", "graph", "summary", + "fixed", "user system", "ja", availableSkills, [], "capability", "graph", "summary", [new ConversationMessage("user", "old"), new ConversationMessage("assistant", "reply")], "new request", ["image"])); - Assert.Equal(["fixed", "user system", "Respond in locale: ja.", "skill", "capability", "graph", "summary", "old", "reply", "new request"], messages.Select(x => x.Content)); + // Check that the skill catalog (name + description) is present, not the full instructions. + Assert.Contains(messages, m => m.Content.Contains("test-skill") && m.Content.Contains("A test skill.")); + Assert.DoesNotContain(messages, m => m.Content == "skill instructions"); Assert.Equal("system", messages[0].Role); Assert.Equal("user", messages[^1].Role); Assert.Equal(["image"], messages[^1].Images); |
