summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-12 00:02:23 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-12 00:02:23 +0900
commitce550a127b7dc972651bd15b9f09979a2c38688f (patch)
treef4e09d0f64f74a585e015e6f548c5589fd2442ba /src
parent55db81082f9165a3965f860cd618bac4242c56b1 (diff)
feat: harden Python workflow and structured planning
Replace the uv-specific skill with a portable Python skill and rewrite the default user system prompt as a concise, structured Markdown execution policy. Normalize a single fenced structured-JSON response, retry invalid skill selection with think disabled, validate selected skill output, and make coding plans keep editing and verification in one task. Document the file_edit patch payload, expose that contract in the tool catalog, and convert malformed patch input into a retryable tool failure instead of terminating the agent task. Update configuration and runtime tests for the Python skill, exact tool catalog, structured-output retry, and implementation-and-verification planning guidance. Verified with 82 Agent.Core tests and an agent-mcp Python scheduling scenario with 49 passing unittest cases.
Diffstat (limited to 'src')
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs61
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs64
-rw-r--r--src/Agent.Core/Runtime/StructuredJson.cs22
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs2
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs28
5 files changed, 127 insertions, 50 deletions
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 4aaf646..31a4414 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -426,34 +426,46 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
string catalog = string.Join("\n", settings.Skills.Select(static s => $"- {s.Name}: {s.Description}"));
var selectionMessages = new List<OllamaMessage>
{
- 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", task.Request)
+ new("system", $"You are a skill classifier, not a task executor. Do not solve the request, inspect files, write code, or call Tools. Available skills:\n{catalog}\n\nReturn only one JSON object with a 'selected_skills' array containing exact skill names. Select only relevant skills. If none are relevant, return an empty array."),
+ new("user", $"Classify skills for this request only:\n{task.Request}")
};
string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
using JsonDocument formatDoc = JsonDocument.Parse(schema);
- OllamaResult result;
- AgentLlmCallContext? callContext = null;
- try
+ IReadOnlyList<OllamaMessage> attemptMessages = selectionMessages;
+ for (int attempt = 0; attempt < 2; attempt++)
{
if (counters.Iterations >= settings.MaxIterations) return [];
int iteration = counters.Iterations++;
- callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null,
+ var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null,
"skill_selection", iteration, counters.Replans, settings.Model);
- _observer.LlmRequest(callContext, selectionMessages);
- result = await ollama.ChatStructuredAsync(selectionMessages, task.Temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
- _observer.LlmResponse(callContext, result);
- registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens);
- registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans);
- }
- catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
- {
- if (callContext is not null) _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message);
- return [];
+ _observer.LlmRequest(callContext, attemptMessages);
+ OllamaResult? result = null;
+ try
+ {
+ bool? thinkOverride = attempt == 0 ? null : false;
+ result = await ollama.ChatStructuredAsync(attemptMessages, task.Temperature, formatDoc.RootElement.Clone(), thinkOverride, cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(callContext, result);
+ registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens);
+ registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans);
+ if (TryParseSelectedSkills(result.Content, out IReadOnlyList<AgentSkill> selected)) return selected;
+ throw new PlannerValidationException("Ollama returned an invalid skill selection object.");
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message);
+ if (attempt == 0)
+ {
+ attemptMessages = selectionMessages.Concat([
+ new OllamaMessage("system", "The previous response was invalid. Return only {\"selected_skills\":[\"name\"]} or {\"selected_skills\":[]}, with no Markdown, code, Tool call, or commentary.")
+ ]).ToArray();
+ }
+ }
}
- return ParseSelectedSkills(result.Content);
+ return [];
}
private void RecordLlmAttempt(TaskSnapshot task, ExecutionCounters counters, string phase,
@@ -525,15 +537,19 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
});
}
- private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
+ private bool TryParseSelectedSkills(string content, out IReadOnlyList<AgentSkill> selected)
{
+ selected = [];
try
{
- using JsonDocument doc = JsonDocument.Parse(content);
- if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return [];
+ using JsonDocument doc = JsonDocument.Parse(StructuredJson.Unwrap(content));
+ if (doc.RootElement.ValueKind != JsonValueKind.Object ||
+ !doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement) ||
+ skillsElement.ValueKind != JsonValueKind.Array) return false;
var selectedNames = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonElement item in skillsElement.EnumerateArray())
{
+ if (item.ValueKind != JsonValueKind.String) return false;
string? name = item.GetString();
if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name);
}
@@ -546,11 +562,12 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
if (valid.Count >= 10) break;
}
}
- return valid;
+ selected = valid;
+ return true;
}
catch
{
- return [];
+ return false;
}
}
diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs
index 2a006f7..bc96ec7 100644
--- a/src/Agent.Core/Runtime/PlannerService.cs
+++ b/src/Agent.Core/Runtime/PlannerService.cs
@@ -186,7 +186,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
{
try
{
- return JsonSerializer.Deserialize<PlanResult>(content, new JsonSerializerOptions
+ return JsonSerializer.Deserialize<PlanResult>(StructuredJson.Unwrap(content), new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
@@ -205,30 +205,45 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
string catalog = string.Join("\n", _settings.Skills.Select(static s => $"- {s.Name}: {s.Description}"));
var selectionMessages = new List<OllamaMessage>
{
- 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)
+ new("system", $"You are a skill classifier, not a task executor. Do not solve the request, inspect files, write code, or call Tools. Available skills:\n{catalog}\n\nReturn only one JSON object with a 'selected_skills' array containing exact skill names. Select only relevant skills. If none are relevant, return an empty array."),
+ new("user", $"Classify skills for this request only:\n{request}")
};
string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
using JsonDocument formatDoc = JsonDocument.Parse(schema);
- try
- {
- OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
- observeAttempt?.Invoke("skill_selection", selectionMessages, result, null);
- return ParseSelectedSkills(result.Content);
- }
- catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ IReadOnlyList<OllamaMessage> attemptMessages = selectionMessages;
+ for (int attempt = 0; attempt < 2; attempt++)
{
- observeAttempt?.Invoke("skill_selection", selectionMessages, null, ex);
- return [];
+ OllamaResult? result = null;
+ try
+ {
+ bool? thinkOverride = attempt == 0 ? null : false;
+ result = await _ollama.ChatStructuredAsync(attemptMessages, temperature, formatDoc.RootElement.Clone(), thinkOverride, cancellationToken).ConfigureAwait(false);
+ if (!TryParseSelectedSkills(result.Content, out IReadOnlyList<AgentSkill> selected))
+ throw new PlannerValidationException("Ollama returned an invalid skill selection object.");
+ observeAttempt?.Invoke("skill_selection", attemptMessages, result, null);
+ return selected;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ observeAttempt?.Invoke("skill_selection", attemptMessages, result, ex);
+ if (attempt == 0)
+ {
+ attemptMessages = selectionMessages.Concat([
+ new OllamaMessage("system", "The previous response was invalid. Return only {\"selected_skills\":[\"name\"]} or {\"selected_skills\":[]}, with no Markdown, code, Tool call, or commentary.")
+ ]).ToArray();
+ }
+ }
}
+ return [];
}
private async Task<TaskGraph> RequestValidGraphAsync(IReadOnlyList<OllamaMessage> messages, double? temperature, string phase,
Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, Func<PlanResult, TaskGraph> buildGraph, CancellationToken cancellationToken)
{
- Exception? firstFailure = null;
+ Exception? lastFailure = null;
IReadOnlyList<OllamaMessage> attemptMessages = messages;
for (int attempt = 0; attempt < 2; attempt++)
{
@@ -248,7 +263,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
{
observeAttempt?.Invoke(phase, attemptMessages, result, ex);
- firstFailure ??= ex;
+ lastFailure = ex;
if (attempt == 0)
{
attemptMessages = messages.Concat([
@@ -259,18 +274,22 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
}
}
}
- throw new PlannerValidationException($"Structured {phase} failed after think=false retry: {firstFailure?.Message}");
+ throw new PlannerValidationException($"Structured {phase} failed after think=false retry: {lastFailure?.Message}");
}
- private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
+ private bool TryParseSelectedSkills(string content, out IReadOnlyList<AgentSkill> selected)
{
+ selected = [];
try
{
- using JsonDocument doc = JsonDocument.Parse(content);
- if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return [];
+ using JsonDocument doc = JsonDocument.Parse(StructuredJson.Unwrap(content));
+ if (doc.RootElement.ValueKind != JsonValueKind.Object ||
+ !doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement) ||
+ skillsElement.ValueKind != JsonValueKind.Array) return false;
var selectedNames = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonElement item in skillsElement.EnumerateArray())
{
+ if (item.ValueKind != JsonValueKind.String) return false;
string? name = item.GetString();
if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name);
}
@@ -283,11 +302,12 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
if (valid.Count >= 10) break;
}
}
- return valid;
+ selected = valid;
+ return true;
}
catch
{
- return [];
+ return false;
}
}
@@ -310,14 +330,16 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
- budget_class: one of 1K, 2K, 4K, 8K, 16K, 32K, 64K, 128K (token budget for this task)
- optimistic_tokens, most_likely_tokens, pessimistic_tokens: PERT estimates (0 < O <= M <= P <= budget)
- requires_external_effect: true if this task requires a verifiable external effect (file edit, process run, etc.)
- - required_tools: the exact Internal Tool names whose successful result is required to complete this task; use names from the supplied Tool catalog, or [] for a pure response task
+ - required_tools: the exact Internal Tool names whose successful result is required to complete this task; use only file_search, file_read, file_edit, process_run, task_update, or dir; use [] for a pure response task
For pure analysis or informational tasks, requires_external_effect should be false.
For tasks that modify files, run processes, or produce verifiable side effects, requires_external_effect should be true.
Read-only operations (file_read, file_search, and dir list) do not create an external effect and must use requires_external_effect=false.
A read/search/list task must name file_read, file_search, or dir in required_tools even though requires_external_effect is false. A pure response task uses an empty array. When requires_external_effect is true, required_tools must include the effect Tool that proves this task's own expected effect.
+ File creation and file modification use file_edit. There is no file_write Tool. Directory creation and removal use dir.
Preserve every user-supplied absolute path, executable, cwd, argument array, operation name, and literal value exactly in the task that uses it; never abbreviate these values into a shell-like command.
Keep each task limited to its own described operation. Do not put later dependent operations into an earlier task.
+ For coding work whose edits must be verified, after any read-only inspection create one implementation-and-verification Task requiring both file_edit and process_run. Do not split the initial edit, test edit, first verification, or failure correction into separate Tasks. This lets the same Task make the required edits, stop after an initially successful verification, or correct and rerun after failure. A required Tool with an unsuccessful result cannot complete a Task or defer its correction to a dependent Task.
A single task is valid for simple requests.
""";
diff --git a/src/Agent.Core/Runtime/StructuredJson.cs b/src/Agent.Core/Runtime/StructuredJson.cs
new file mode 100644
index 0000000..e0c1650
--- /dev/null
+++ b/src/Agent.Core/Runtime/StructuredJson.cs
@@ -0,0 +1,22 @@
+namespace Agent.Core.Runtime;
+
+internal static class StructuredJson
+{
+ public static string Unwrap(string content)
+ {
+ string value = content.Trim();
+ int fenceStart = value.IndexOf("```", StringComparison.Ordinal);
+ if (fenceStart < 0) return value;
+ int firstLineEnd = value.IndexOf('\n', fenceStart);
+ if (firstLineEnd < 0) return value;
+ string opening = value[fenceStart..firstLineEnd].TrimEnd('\r');
+ if (!string.Equals(opening, "```", StringComparison.Ordinal) &&
+ !string.Equals(opening, "```json", StringComparison.OrdinalIgnoreCase))
+ return value;
+
+ int fenceEnd = value.IndexOf("```", firstLineEnd + 1, StringComparison.Ordinal);
+ if (fenceEnd < 0 || value.IndexOf("```", fenceEnd + 3, StringComparison.Ordinal) >= 0)
+ return value;
+ return value[(firstLineEnd + 1)..fenceEnd].Trim();
+ }
+}
diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs
index fbb7423..2ada49b 100644
--- a/src/Agent.Core/Tools/InternalToolCatalog.cs
+++ b/src/Agent.Core/Tools/InternalToolCatalog.cs
@@ -17,7 +17,7 @@ public static class InternalToolCatalog
[
Define("file_search", "Search files under an approved root.", """{"type":"object","additionalProperties":false,"required":["root","mode","query"],"properties":{"root":{"type":"string"},"mode":{"type":"string","enum":["glob","name","text","regex","tail"]},"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"maximum":1000},"tail_lines":{"type":"integer","minimum":1,"maximum":10000}}}"""),
Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""),
- Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete","rename"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]},"new_path":{"type":["string","null"]}}}"""),
+ Define("file_edit", "Edit an approved file transactionally. For patch, pass a JSON string containing old_text and new_text; unified diff is unsupported.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete","rename"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]},"new_path":{"type":["string","null"]}}}"""),
Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""),
Define("task_update", "Update the deterministic task graph structure or estimates.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"requires_external_effect":{"type":["boolean","null"]},"required_tools":{"type":"array","items":{"type":"string","enum":["file_search","file_read","file_edit","process_run","task_update","dir"]}}}}"""),
Define("dir", "Create, remove, or list directories under an approved root. Only empty directories can be removed.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["mkdir","rmdir","list"]}}}""")
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index c432894..5cbf853 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -97,6 +97,13 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
ErrorCode: ex.Code, ErrorCategory: ex.Category, TargetPath: ex.TargetPath,
RequiredPermission: ex.RequiredPermission, Resolution: ex.Resolution));
}
+ catch (Exception ex) when (ex is ToolValidationException or JsonException or InvalidOperationException)
+ {
+ return Task.FromResult(new ToolExecutionResult(false, ex.Message, false,
+ $"{toolName}:tool.input_invalid:{ex.Message}", RetryAllowed: true,
+ ErrorCode: "tool.input_invalid", ErrorCategory: "tool",
+ Resolution: "Correct the Tool arguments using the published schema and retry."));
+ }
}
public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken)
@@ -300,12 +307,21 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
private static string ApplyPatch(string path, string patch)
{
string current = File.Exists(path) ? File.ReadAllText(path) : string.Empty;
- using JsonDocument document = JsonDocument.Parse(patch);
- string oldText = document.RootElement.GetProperty("old_text").GetString()!;
- string newText = document.RootElement.GetProperty("new_text").GetString()!;
- int first = current.IndexOf(oldText, StringComparison.Ordinal);
- if (first < 0 || current.IndexOf(oldText, first + oldText.Length, StringComparison.Ordinal) >= 0) throw new InvalidOperationException("Patch old_text must match exactly once.");
- return current[..first] + newText + current[(first + oldText.Length)..];
+ JsonDocument document;
+ try { document = JsonDocument.Parse(patch); }
+ catch (JsonException) { throw new ToolValidationException("Patch must be a JSON object string containing old_text and new_text; unified diff is unsupported."); }
+ using (document)
+ {
+ if (document.RootElement.ValueKind != JsonValueKind.Object ||
+ !document.RootElement.TryGetProperty("old_text", out JsonElement oldElement) || oldElement.ValueKind != JsonValueKind.String ||
+ !document.RootElement.TryGetProperty("new_text", out JsonElement newElement) || newElement.ValueKind != JsonValueKind.String)
+ throw new ToolValidationException("Patch must be a JSON object string containing string old_text and new_text fields.");
+ string oldText = oldElement.GetString()!;
+ string newText = newElement.GetString()!;
+ int first = current.IndexOf(oldText, StringComparison.Ordinal);
+ if (first < 0 || current.IndexOf(oldText, first + oldText.Length, StringComparison.Ordinal) >= 0) throw new InvalidOperationException("Patch old_text must match exactly once.");
+ return current[..first] + newText + current[(first + oldText.Length)..];
+ }
}
private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, Guid sessionId)