summaryrefslogtreecommitdiff
path: root/src/Agent.App
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
commit6c3458a5ec37a5b69957ad17c84de58984698f25 (patch)
treebe5c5716e7488fcf1bc1a58de0ded0b9238f34cb /src/Agent.App
parentb5ccecca01007f2a2459265df6d749fbfd31aa84 (diff)
Audit and align v1 runtime design
Diffstat (limited to 'src/Agent.App')
-rw-r--r--src/Agent.App/Cli/CommandLine.cs6
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs69
-rw-r--r--src/Agent.App/Logging/JsonLinesRuntimeObserver.cs46
-rw-r--r--src/Agent.App/Program.cs23
4 files changed, 122 insertions, 22 deletions
diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs
index df65080..3fdac9f 100644
--- a/src/Agent.App/Cli/CommandLine.cs
+++ b/src/Agent.App/Cli/CommandLine.cs
@@ -6,8 +6,8 @@ public sealed record CommandLine(AppCommand Command, string? ConfigPath, string?
{
public static CommandLine Parse(string[] args)
{
- if (args.Length == 0) throw new CommandLineException("Expected 'serve' or 'check'.");
- AppCommand command = args[0] switch
+ bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal);
+ AppCommand command = implicitServe ? AppCommand.Serve : args[0] switch
{
"serve" => AppCommand.Serve,
"check" => AppCommand.Check,
@@ -17,7 +17,7 @@ public sealed record CommandLine(AppCommand Command, string? ConfigPath, string?
string? config = null;
string? language = null;
string? transport = null;
- for (int i = 1; i < args.Length; i++)
+ for (int i = implicitServe ? 0 : 1; i < args.Length; i++)
{
string option = args[i];
if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'.");
diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs
index 298a143..efb3db1 100644
--- a/src/Agent.App/Configuration/ConfigurationLoader.cs
+++ b/src/Agent.App/Configuration/ConfigurationLoader.cs
@@ -2,6 +2,7 @@ using System.Net;
using System.Text.RegularExpressions;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
+using YamlDotNet.RepresentationModel;
namespace Agent.App.Configuration;
@@ -17,10 +18,14 @@ public static partial class ConfigurationLoader
try
{
+ string yaml = File.ReadAllText(absolutePath);
+ string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml");
+ if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath))
+ yaml = MergeYaml(yaml, File.ReadAllText(localPath));
var deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
- AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(File.ReadAllText(absolutePath))
+ AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(yaml)
?? throw new ConfigurationException("Configuration document is empty.");
Validate(configuration, Path.GetDirectoryName(absolutePath)!);
return configuration;
@@ -60,6 +65,16 @@ public static partial class ConfigurationLoader
foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals)
{
if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters.");
+ string approvalPath = Path.GetFullPath(approval.Path);
+ bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath));
+ if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute.");
+ }
+ foreach (string executeEntry in value.Sandbox.Filesystem.Execute)
+ {
+ string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry);
+ if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval =>
+ string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase)))
+ Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals.");
}
if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid.");
if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid.");
@@ -81,6 +96,58 @@ public static partial class ConfigurationLoader
}
public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory);
+
+ private static bool ContainsPath(string baseDirectory, string configuredPath, string target)
+ {
+ string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath);
+ if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true;
+ string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
+ return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string MergeYaml(string baseYaml, string localYaml)
+ {
+ YamlNode baseRoot = LoadRoot(baseYaml, "base configuration");
+ YamlNode localRoot = LoadRoot(localYaml, "agent.local.yaml");
+ YamlNode merged = MergeNode(baseRoot, localRoot);
+ var stream = new YamlStream(new YamlDocument(merged));
+ using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture);
+ stream.Save(writer, assignAnchors: false);
+ return writer.ToString();
+ }
+
+ private static YamlNode LoadRoot(string yaml, string source)
+ {
+ var stream = new YamlStream();
+ using var reader = new StringReader(yaml);
+ stream.Load(reader);
+ if (stream.Documents.Count != 1) Throw($"{source} must contain exactly one YAML document.");
+ return stream.Documents[0].RootNode;
+ }
+
+ private static YamlNode MergeNode(YamlNode baseNode, YamlNode localNode)
+ {
+ if (baseNode is not YamlMappingNode baseMap || localNode is not YamlMappingNode localMap)
+ return Clone(localNode);
+
+ var result = (YamlMappingNode)Clone(baseMap);
+ foreach ((YamlNode key, YamlNode localValue) in localMap.Children)
+ {
+ YamlNode? existingKey = result.Children.Keys.FirstOrDefault(candidate => candidate is YamlScalarNode a && key is YamlScalarNode b && string.Equals(a.Value, b.Value, StringComparison.Ordinal));
+ if (existingKey is null) result.Add(Clone(key), Clone(localValue));
+ else result.Children[existingKey] = MergeNode(result.Children[existingKey], localValue);
+ }
+ return result;
+ }
+
+ private static YamlNode Clone(YamlNode node)
+ {
+ var stream = new YamlStream(new YamlDocument(node));
+ using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture);
+ stream.Save(writer, assignAnchors: false);
+ return LoadRoot(writer.ToString(), "merged configuration");
+ }
+
private static void Throw(string message) => throw new ConfigurationException(message);
[GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)]
private static partial Regex Sha256Regex();
diff --git a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
index 20ef88d..78ad47b 100644
--- a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
+++ b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
@@ -5,33 +5,51 @@ using Agent.Core.Tools;
namespace Agent.App.Logging;
-public sealed class JsonLinesRuntimeObserver(string logDirectory) : IAgentRuntimeObserver
+public sealed class JsonLinesRuntimeObserver : IAgentRuntimeObserver, IDisposable
{
- private readonly object _sync = new();
- private readonly string _path = Path.Combine(logDirectory, "llm.jsonl");
+ private readonly RollingFileWriter _writer;
- public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) => Write(new
+ public JsonLinesRuntimeObserver(string logDirectory, long maxBytes = 10 * 1024 * 1024, int retainedFiles = 5) =>
+ _writer = new RollingFileWriter(Path.Combine(logDirectory, "llm.jsonl"), maxBytes, retainedFiles);
+
+ public void LlmRequest(AgentLlmCallContext context, IReadOnlyList<OllamaMessage> messages) => Write(new
{
- timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.request", iteration,
- messages = messages.Select(static message => new { message.Role, message.Content, image_count = message.Images?.Count ?? 0, message.ToolName })
+ timestamp = DateTimeOffset.UtcNow, event_name = "llm.request", call_id = context.CallId,
+ session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId,
+ phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model,
+ messages = messages.Select(static message => new { role = message.Role, content = message.Content, image_count = message.Images?.Count ?? 0, tool_name = message.ToolName })
});
- public void LlmResponse(Guid taskId, int iteration, OllamaResult result) => Write(new
+ public void LlmResponse(AgentLlmCallContext context, OllamaResult result) => Write(new
{
- timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.response", iteration,
- result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens,
+ timestamp = DateTimeOffset.UtcNow, event_name = "llm.response", call_id = context.CallId,
+ session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId,
+ phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model,
+ content = result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens,
tool_calls = result.ToolCalls?.Select(static call => call.Function.Name).ToArray() ?? []
});
- public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) => Write(new
+ public void LlmFailure(AgentLlmCallContext context, string errorCode, string message) => Write(new
{
- timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.tool_result", tool_name = toolName,
- result.Success, result.ExternalEffect, result.RetryAllowed, result.Output, result.FailureFingerprint
+ timestamp = DateTimeOffset.UtcNow, event_name = "llm.failure", call_id = context.CallId,
+ session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId,
+ phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model,
+ error_code = errorCode, message
+ });
+
+ public void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result) => Write(new
+ {
+ timestamp = DateTimeOffset.UtcNow, event_name = "llm.tool_result", call_id = context.CallId,
+ session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId,
+ phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, tool_name = toolName,
+ success = result.Success, external_effect = result.ExternalEffect, retry_allowed = result.RetryAllowed,
+ output = result.Output, failure_fingerprint = result.FailureFingerprint
});
private void Write(object value)
{
- string line = JsonSerializer.Serialize(value);
- lock (_sync) File.AppendAllText(_path, line + Environment.NewLine);
+ _writer.WriteLine(JsonSerializer.Serialize(value));
}
+
+ public void Dispose() => _writer.Dispose();
}
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 1c29ec3..6f3485f 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -76,7 +76,7 @@ internal static class Program
var sessionStore = new SessionStore();
InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore);
- var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory);
+ using var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles);
string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks");
UserOnlyDirectory.Ensure(taskStateDirectory);
var taskStoreSettings = new McpTaskStoreSettings(taskStateDirectory,
@@ -88,7 +88,7 @@ internal static class Program
string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
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"));
+ AgentRuntimeSettings runtimeSettings = CreateRuntimeSettings(configuration, userPrompt, language, skills, locale);
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,
toolDispatcher, runtimeObserver, taskStoreSettings, hostObserver, sessionStore: sessionStore).ConfigureAwait(false);
@@ -99,7 +99,7 @@ internal static class Program
string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
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"));
+ AgentRuntimeSettings runtimeSettings = CreateRuntimeSettings(configuration, userPrompt, language, skills, locale);
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,
configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver,
@@ -143,7 +143,8 @@ internal static class Program
configuration.Sandbox.Network.Mode,
configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
- configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines);
+ configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines,
+ configuration.Sandbox.Resources.ProcessTimeoutSeconds);
var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore));
var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal);
return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend);
@@ -153,4 +154,18 @@ internal static class Program
foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability));
}
}
+
+ private static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language,
+ IReadOnlyList<Agent.Core.Skills.AgentSkill> skills, LocaleCatalog locale) => new(
+ 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"),
+ configuration.Ollama.Options.NumCtx ?? 32768,
+ configuration.Limits.ContextSummaryRatio,
+ MaxTasks: configuration.Planner.MaxTasks,
+ Model: configuration.Ollama.Model);
}