diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
| commit | b07b41cd435991cd254957af79055080c85b346e (patch) | |
| tree | 27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Core/Ollama | |
| parent | 2fefd29e86c3b926d0cf4a362251d3f119688612 (diff) | |
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。
.NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。
Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。
Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。
Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.Core/Ollama')
| -rw-r--r-- | src/Agent.Core/Ollama/IOllamaClient.cs | 11 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaClient.cs | 92 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaException.cs | 7 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaModels.cs | 46 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaSettings.cs | 10 |
5 files changed, 166 insertions, 0 deletions
diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs new file mode 100644 index 0000000..a03a353 --- /dev/null +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Ollama; + +using Agent.Core.Tools; + +public interface IOllamaClient +{ + Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken); + Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) => + ChatAsync(messages, temperatureOverride, cancellationToken); + Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(null); +} diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs new file mode 100644 index 0000000..bfd2210 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed class OllamaClient : IOllamaClient +{ + private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1)]; + private readonly HttpClient _httpClient; + private readonly OllamaSettings _settings; + private readonly Func<TimeSpan, CancellationToken, Task> _delay; + private bool? _visionCapability; + + public OllamaClient(HttpClient httpClient, OllamaSettings settings, Func<TimeSpan, CancellationToken, Task>? delay = null) + { + _httpClient = httpClient; + _httpClient.BaseAddress = settings.Endpoint; + _settings = settings; + _delay = delay ?? Task.Delay; + } + + public async Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, cancellationToken).ConfigureAwait(false); + + public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, tools, cancellationToken).ConfigureAwait(false); + + public async Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) + { + if (_visionCapability is not null) return _visionCapability; + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/show", new { model = _settings.Model }, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return null; + using JsonDocument document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), cancellationToken: cancellationToken).ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("capabilities", out JsonElement capabilities) || capabilities.ValueKind != JsonValueKind.Array) return null; + _visionCapability = capabilities.EnumerateArray().Any(static item => item.GetString()?.Equals("vision", StringComparison.OrdinalIgnoreCase) == true); + return _visionCapability; + } + catch (HttpRequestException) { return null; } + } + + private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, CancellationToken cancellationToken) + { + Dictionary<string, JsonElement> 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); + var request = new OllamaChatRequest + { + Model = _settings.Model, + Messages = messages, + Stream = false, + 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() + }; + + for (int attempt = 0; ; attempt++) + { + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/chat", request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + bool transient = IsTransient(response.StatusCode); + if (transient && attempt < RetryDelays.Length) + { + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + continue; + } + throw new OllamaException($"Ollama returned HTTP {(int)response.StatusCode}: {body}", transient); + } + + OllamaChatResponse result = await response.Content.ReadFromJsonAsync<OllamaChatResponse>(cancellationToken: cancellationToken).ConfigureAwait(false) + ?? throw new OllamaException("Ollama returned an empty response.", false); + if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false); + if (result.Message is null) throw new OllamaException("Ollama response has no message.", false); + return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + if (attempt >= RetryDelays.Length) throw new OllamaException("Ollama is unavailable after three retries.", true, ex); + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + } + } + } + + private static bool IsTransient(HttpStatusCode statusCode) => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; +} diff --git a/src/Agent.Core/Ollama/OllamaException.cs b/src/Agent.Core/Ollama/OllamaException.cs new file mode 100644 index 0000000..6b0c158 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaException.cs @@ -0,0 +1,7 @@ +namespace Agent.Core.Ollama; + +public sealed class OllamaException(string message, bool transient, Exception? innerException = null) + : Exception(message, innerException) +{ + public bool IsTransient { get; } = transient; +} diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs new file mode 100644 index 0000000..823a178 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaModels.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed record OllamaMessage( + [property: JsonPropertyName("role")] string Role, + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<string>? Images = null, + [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null, + [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<OllamaToolCall>? ToolCalls = null); + +public sealed record OllamaToolCall([property: JsonPropertyName("function")] OllamaFunctionCall Function); +public sealed record OllamaFunctionCall( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("arguments")] JsonElement Arguments); + +public sealed record OllamaToolDefinition( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("function")] OllamaFunctionDefinition Function); +public sealed record OllamaFunctionDefinition( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("parameters")] JsonElement Parameters); + +public sealed class OllamaChatRequest +{ + [JsonPropertyName("model")] public required string Model { get; init; } + [JsonPropertyName("messages")] public required IReadOnlyList<OllamaMessage> Messages { get; init; } + [JsonPropertyName("stream")] public bool Stream { get; init; } + [JsonPropertyName("think")] public bool Think { get; init; } + [JsonPropertyName("keep_alive")] public required string KeepAlive { get; init; } + [JsonPropertyName("options")] public required IReadOnlyDictionary<string, System.Text.Json.JsonElement> Options { get; init; } + [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList<OllamaToolDefinition>? Tools { get; init; } +} + +public sealed class OllamaChatResponse +{ + [JsonPropertyName("message")] public OllamaMessage? Message { get; init; } + [JsonPropertyName("prompt_eval_count")] public long PromptEvalCount { get; init; } + [JsonPropertyName("eval_count")] public long EvalCount { get; init; } + [JsonPropertyName("error")] public string? Error { get; init; } +} + +public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null); diff --git a/src/Agent.Core/Ollama/OllamaSettings.cs b/src/Agent.Core/Ollama/OllamaSettings.cs new file mode 100644 index 0000000..94ab3ef --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaSettings.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace Agent.Core.Ollama; + +public sealed record OllamaSettings( + Uri Endpoint, + string Model, + bool Think, + string KeepAlive, + IReadOnlyDictionary<string, JsonElement> Options); |
