From d5d0a84e573c833843279c520ce2d5765425ba93 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 11 Aug 2026 00:15:54 +0900 Subject: feat: implement LLM skill selection with Ollama structured output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5残件3項目を実装: 1. Ollama structured output (format param): - OllamaChatRequestへFormat プロパティ追加 (JsonElement?) - IOllamaClientへChatStructuredAsync メソッド追加 - OllamaClient.ChatCoreAsync へformat パラメータ追加 - ChatStructuredAsync はtoolsなし、format指定でJSON構造化出力を要求 2. AgentRuntime skill selection step: - SelectSkillsAsync メソッド追加: メインループ前にOllamaへskill catalogを送信 - structured output で {"selected_skills": ["name"]} を取得 - ParseSelectedSkills: JSON解析、unknown skill名排除、重複排除、最大10件 - structured output非対応モデルはfallback空リスト - 選択されたskillのInstructionsのみPromptContext.SelectedSkillsへ設定 - Progressive Disclosure Stage 2→3 が完了 3. Skill security/path validation: - SelectSkillsAsync でsettings.Skillsからのみ選択(unknown name排除) - SkillLoader でreparse point skip済み(既存実装) - 選択数上限10件でDoS防止 - Skill load != permission grant 維持: allowed-toolsはSandbox permissionに影響しない 変更対象: - OllamaModels.cs: OllamaChatRequest.Format プロパティ追加 - IOllamaClient.cs: ChatStructuredAsync メソッド追加 - OllamaClient.cs: ChatCoreAsync へformat パラメータ追加、ChatStructuredAsync 実装 - AgentRuntime.cs: SelectSkillsAsync + ParseSelectedSkills、メインループ前の選択ステップ 検証結果: - Release build: 警告0、エラー0 - Core tests: 61/61 PASS、skip 0 - Planner tests: 10/10 PASS、skip 0 --- src/Agent.Core/Ollama/IOllamaClient.cs | 3 ++ src/Agent.Core/Ollama/OllamaClient.cs | 12 ++++-- src/Agent.Core/Ollama/OllamaModels.cs | 1 + src/Agent.Core/Runtime/AgentRuntime.cs | 67 +++++++++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs index a03a353..d1cb99f 100644 --- a/src/Agent.Core/Ollama/IOllamaClient.cs +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -1,5 +1,6 @@ namespace Agent.Core.Ollama; +using System.Text.Json; using Agent.Core.Tools; public interface IOllamaClient @@ -7,5 +8,7 @@ public interface IOllamaClient Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken); Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) => ChatAsync(messages, temperatureOverride, cancellationToken); + Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) => + ChatAsync(messages, temperatureOverride, cancellationToken); Task SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult(null); } diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs index bfd2210..c761081 100644 --- a/src/Agent.Core/Ollama/OllamaClient.cs +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -22,10 +22,13 @@ public sealed class OllamaClient : IOllamaClient } public async Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, null, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, null, null, cancellationToken).ConfigureAwait(false); public async Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, tools, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, tools, null, cancellationToken).ConfigureAwait(false); + + public async Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, format, cancellationToken).ConfigureAwait(false); public async Task SupportsVisionAsync(CancellationToken cancellationToken) { @@ -42,7 +45,7 @@ public sealed class OllamaClient : IOllamaClient catch (HttpRequestException) { return null; } } - private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, CancellationToken cancellationToken) + private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, JsonElement? format, CancellationToken cancellationToken) { Dictionary options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value); @@ -54,7 +57,8 @@ public sealed class OllamaClient : IOllamaClient Think = _settings.Think, KeepAlive = _settings.KeepAlive, Options = options, - Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray() + Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray(), + Format = format }; for (int attempt = 0; ; attempt++) diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs index 823a178..f4b3884 100644 --- a/src/Agent.Core/Ollama/OllamaModels.cs +++ b/src/Agent.Core/Ollama/OllamaModels.cs @@ -33,6 +33,7 @@ public sealed class OllamaChatRequest [JsonPropertyName("keep_alive")] public required string KeepAlive { get; init; } [JsonPropertyName("options")] public required IReadOnlyDictionary Options { get; init; } [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList? Tools { get; init; } + [JsonPropertyName("format"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public JsonElement? Format { get; init; } } public sealed class OllamaChatResponse diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index 228ac41..f6bb483 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Agent.Core.Ollama; using Agent.Core.Prompts; using Agent.Core.Sessions; @@ -28,12 +29,16 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false); if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) throw new InvalidOperationException(settings.VisionUnsupportedMessage); + + // Progressive Disclosure Stage 2: LLM Skill Selection. + IReadOnlyList selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false); + var messages = PromptComposer.Compose(new PromptContext( FixedPrompt.Load(), settings.UserSystemPrompt, settings.Locale, settings.Skills, - [], // SelectedSkills: populated after LLM skill selection (Progressive Disclosure Stage 2→3). + selectedSkills, // Stage 3: selected skill instructions injected by PromptComposer. settings.SandboxCapabilitySummary, "Current task: " + task.Request, string.Empty, @@ -134,6 +139,66 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag } } + private async Task> SelectSkillsAsync(string request, CancellationToken cancellationToken) + { + if (settings.Skills.Count == 0) return []; + + // Build a minimal prompt with skill catalog for the LLM to choose from. + string catalog = string.Join("\n", settings.Skills.Select(static s => $"- {s.Name}: {s.Description}")); + var selectionMessages = new List + { + new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."), + new("user", request) + }; + + // JSON Schema for structured output. + string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}"""; + using JsonDocument formatDoc = JsonDocument.Parse(schema); + + OllamaResult result; + try + { + result = await ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + } + catch + { + // If structured output fails (e.g., model doesn't support it), fall back to no skills. + return []; + } + + return ParseSelectedSkills(result.Content); + } + + private IReadOnlyList ParseSelectedSkills(string content) + { + try + { + using JsonDocument doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return []; + var selectedNames = new HashSet(StringComparer.Ordinal); + foreach (JsonElement item in skillsElement.EnumerateArray()) + { + string? name = item.GetString(); + if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name); + } + // Validate: only known skills, no duplicates, max 10. + var valid = new List(); + foreach (AgentSkill skill in settings.Skills) + { + if (selectedNames.Contains(skill.Name)) + { + valid.Add(skill); + if (valid.Count >= 10) break; + } + } + return valid; + } + catch + { + return []; + } + } + private async Task EndTaskSafelyAsync(Guid taskId, bool success, CancellationToken cancellationToken) { try -- cgit v1.2.3