summaryrefslogtreecommitdiff
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
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.
-rw-r--r--doc/Design.md1
-rw-r--r--prompts/user-system.md59
-rw-r--r--skills/python-via-uv/SKILL.md27
-rw-r--r--skills/python-via-uv/agents/openai.yaml4
-rw-r--r--skills/python/SKILL.md30
-rw-r--r--skills/python/agents/openai.yaml4
-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
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs6
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs13
13 files changed, 234 insertions, 87 deletions
diff --git a/doc/Design.md b/doc/Design.md
index 3623cb4..68c7dbc 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -291,6 +291,7 @@ Ollamaに公開する現行Internal Tool catalogは次のとおりとする。å€
}
```
- overwrite/patch/atomic_replaceはwrite permissionを要求する。
+- `patch` operationの`patch` fieldは、JSON文字列化した`{"old_text":"...","new_text":"..."}`を受け取る。unified diffは受け付けない。`old_text`は対象file内で正確に1回だけ一致しなければならない。不正形式または一致数不正は再試行可能なTool failureとする。
- deleteはdelete permissionを要求する。
- renameはsource/destination双方にwrite permissionを要求する。destinationが既に存在する場合は拒否する(overwrite不可)。cross-volume renameは不支持でTool failureとして返す。
- rename transaction: PREPARE(source/destination検査+journal) → APPLY(File.Move) → VERIFY(source不存在/destination存在/hash一致) → COMMIT。crash recoveryはdestination hashがjournal記録と一致する場合のみdestinationからsourceへ戻す。
diff --git a/prompts/user-system.md b/prompts/user-system.md
index f1d255b..9766b67 100644
--- a/prompts/user-system.md
+++ b/prompts/user-system.md
@@ -1,2 +1,57 @@
-あなたはユーザーの身近な相棒です。親しみやすく率直に会話し、考えを整理し、必要なときだけ実作業を提案・実行してください。
-分からないことは決めつけず確認し、利用可能なSkillとToolを選び、結果を確かめてから要点と次の一手を簡潔に伝えてください。
+# Role
+
+あなたは、依頼を安全かつ確実に完了する実行アシスタントです。
+親しみやすく率直に対話し、結果を先に簡潔に伝えてください。
+
+# Priorities
+
+1. ユーザーの明示した目的、制約、値を守る。
+2. Sandbox Policyと利用可能なToolの範囲を守る。
+3. 実行結果を確認してから完了を報告する。
+4. 不明点を推測で埋めず、結果を変える判断だけ確認する。
+
+# Understand the Request
+
+- 目的、成果物、作業directory、入力、出力、完了条件を整理する。
+- 指定されたpath、実行file、引数、文字列、数値を省略または変更しない。
+- 既存fileを変更する場合は、先に必要な範囲を読み取る。
+- 依頼されていない副作用や権限拡張を計画しない。
+
+# Plan and Execute
+
+- 単純な依頼は小さなTaskとして、複雑な依頼は検証可能なTaskへ分ける。
+- 各Taskは一つの明確な成果に限定し、dependency順に実行する。
+- 利用可能なSkillが依頼に合う場合は、その手順を適用する。
+- 読取、編集、directory操作、process実行には対応するInternal Toolを使う。
+- Tool呼出しでは構造化引数を使い、shell形式の文字列へまとめない。
+- Tool失敗時は同じ呼出しを反復せず、errorと許可済みの代替を確認する。
+- Sandbox拒否を回避せず、必要なpath、権限、承認を具体的に示す。
+
+# Working with Files
+
+- 作業は指定されたdirectory内に限定する。
+- 複雑な成果物は再利用可能なfileとして保存する。
+- 既存内容を維持する指定がある場合は、変更を必要最小限にする。
+- 一時file、backup、生成物を把握し、不要な残留を避ける。
+
+# Running Programs
+
+- 承認済みの実行fileだけを直接起動する。
+- executable、cwd、argsを分け、引数の順序と値を維持する。
+- 作成するprogramやtestからdescendant processを生成しない。
+- 依頼なしにpackageをinstallしたりnetworkへ接続したりしない。
+- stdout、stderr、exit codeを確認し、期待結果と照合する。
+
+# Verification
+
+- 成果物の存在だけでなく、主要動作、境界条件、失敗条件を確認する。
+- 可能なら決定的なtestを実行する。
+- 必要なTool結果が成功するまでTaskを完了扱いしない。
+- 未検証、skip、外部要因による失敗を成功として報告しない。
+
+# Communication
+
+- 実行中は必要な進捗だけを短く伝える。
+- 最終応答には、結果、変更file、検証内容、残課題を含める。
+- 内部Tool名、Sandbox実装詳細、秘密情報、隠れた推論は利用者向け結果へ出さない。
+- 失敗時は原因と、ユーザーが次に取れる具体的な一手を示す。
diff --git a/skills/python-via-uv/SKILL.md b/skills/python-via-uv/SKILL.md
deleted file mode 100644
index d840e4d..0000000
--- a/skills/python-via-uv/SKILL.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-name: python-via-uv
-description: uv経由でPythonコードを作成・実行・検証する。ユーザーがPythonによる計算、データ処理、自動化、試作、デバッグ、またはスクリプト実行を求めたときに使用する。
----
-
-# Python via uv
-
-1. 作業directoryと必要な入出力を確認し、コードは許可されたdirectory内の`.py`へ`file_edit`で保存する。複雑なコードを`python -c`へ詰め込まない。
-2. 承認済み`uv.exe`を`process_run`し、projectではcwdから`uv python find --offline --no-python-downloads --resolve-links`、単独scriptでは同じ引数へ`--no-project`を追加してinterpreterの絶対pathを取得する。
-3. 取得した承認済み`python.exe`を別の`process_run`で起動し、`-I <script>`を渡す。shellを経由せず、引数は配列で分離する。
-4. v1 Sandboxは全descendant生成を禁止するため、`uv run`は使用しない。uvが返したPythonをBrokerから直接起動することで、両方のpath/SHA-256 approvalとchild-process禁止を維持する。
-5. dependencyが環境にない場合は勝手にnetwork接続やinstallを行わず、必要なpackageと許可をユーザーへ確認する。依頼なしに`uv add`や`uv pip install`を実行しない。
-6. `stdout`、`stderr`、exit codeを確認する。exit code 0と期待結果を確認できた場合だけ成功と報告し、生成・変更したfileも示す。
-
-`process_run`の基本形:
-
-```text
-executable: <承認済みuv.exe>
-args: ["python", "find", "--offline", "--no-python-downloads", "--resolve-links", "--no-project"]
-cwd: <許可された作業directory>
-
-executable: <uvが返した承認済みpython.exe>
-args: ["-I", "script.py"]
-cwd: <許可された作業directory>
-```
-
-`uv.exe`または`python.exe`が未承認、あるいは作業directoryのread/write/execute権限が不足している場合は迂回せず、必要な設定を具体的に報告する。
diff --git a/skills/python-via-uv/agents/openai.yaml b/skills/python-via-uv/agents/openai.yaml
deleted file mode 100644
index de95aab..0000000
--- a/skills/python-via-uv/agents/openai.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-interface:
- display_name: "Python via uv"
- short_description: "Run reproducible Python tasks safely through uv"
- default_prompt: "Use $python-via-uv to create and run Python code, then verify the result."
diff --git a/skills/python/SKILL.md b/skills/python/SKILL.md
new file mode 100644
index 0000000..966a4fd
--- /dev/null
+++ b/skills/python/SKILL.md
@@ -0,0 +1,30 @@
+---
+name: python
+description: Pythonコードを作成・実行・検証する。計算、データ処理、自動化、CLI、試作、デバッグ、またはPython testを求められたときに使用する。
+---
+
+# Python
+
+## Workflow
+
+1. 目的、入出力、作業directory、Python version、完了条件を確認する。
+2. 既存projectでは関連fileを先に読み、新規作成では標準ライブラリで満たせるか確認する。
+3. 複雑なコードは許可された作業directory内の`.py`へ保存する。`python -c`へ長いコードを埋め込まない。
+ - 小さな修正で`file_edit`の`patch` operationを使う場合、`patch` fieldにはJSON文字列`{"old_text":"置換前","new_text":"置換後"}`を渡す。unified diffや`@@`形式を渡さない。置換前文字列はfile内で正確に1回だけ一致させる。
+4. 承認済みのPython executableを直接`process_run`し、cwdとargsを分ける。shell、launcher、環境探索Toolを経由しない。
+5. 通常のscriptは`-I <script>`で実行する。単一test scriptは`unittest.main()`を持たせて`-I <test-script>`で実行するか、`-I -m unittest discover -s <test-directory> -p <pattern> -v`で明示的に探索する。isolated modeでimport不能になるfile pathを`-m unittest`へ直接渡さない。
+6. test codeでも`subprocess`、`multiprocessing`、`os.system`を使用しない。CLIはentry functionへ引数とI/Oを渡し、同一process内で検証する。
+7. 正常系に加え、重要な境界条件と失敗条件を決定的なtestで確認する。
+8. stdout、stderr、exit codeを確認し、exit code 0と期待結果の両方を満たした場合だけ成功と報告する。
+
+## Dependency Rules
+
+- 標準ライブラリを優先する。
+- dependencyが不足していても、依頼なしにnetwork接続やpackage installを行わない。
+- 外部packageが必要な場合は、package名、理由、必要な許可をユーザーへ示す。
+
+## Safety and Reporting
+
+- Python executableまたは作業directoryの権限が不足している場合は迂回しない。
+- descendant processを生成するコードや、依頼されていないfilesystem/network操作を追加しない。
+- 最終応答では作成・変更したfile、実行したtest、exit code、主要結果、未検証事項を簡潔に示す。
diff --git a/skills/python/agents/openai.yaml b/skills/python/agents/openai.yaml
new file mode 100644
index 0000000..dff8b03
--- /dev/null
+++ b/skills/python/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Python"
+ short_description: "Create, run, and verify Python programs safely"
+ default_prompt: "Use $python to create and run Python code, then verify the result."
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)
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs
index f6db9f2..64b2d85 100644
--- a/tests/Agent.Core.Tests/ConfigurationTests.cs
+++ b/tests/Agent.Core.Tests/ConfigurationTests.cs
@@ -139,14 +139,14 @@ public sealed class ConfigurationTests
// Legacy single markdown — should be ignored (not loaded).
directory.File("skills/legacy.md", "legacy 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.");
+ directory.File("skills/python/SKILL.md", "---\nname: python\ndescription: Python execution skill.\n---\n# Python\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");
+ directory.File("skills/python/reference.md", "must not be loaded as a skill");
var skills = SkillLoader.Load(Path.Combine(directory.Path, "skills"));
Assert.Single(skills);
- Assert.Equal("python-via-uv", skills[0].Name);
+ Assert.Equal("python", skills[0].Name);
Assert.Equal("Python execution skill.", skills[0].Description);
Assert.Contains("Instructions here.", skills[0].Instructions);
}
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index fef5340..6dad528 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -284,6 +284,10 @@ public sealed class PromptAndRuntimeTests
Assert.Equal(AgentTaskState.Completed, completed!.State);
Assert.Equal([null, false], ollama.ThinkOverrides);
Assert.All(ollama.Temperatures, value => Assert.Equal(0.7, value));
+ Assert.Contains(ollama.PlanningMessages[0], message =>
+ message.Role == "system" && message.Content.Contains("There is no file_write Tool", StringComparison.Ordinal));
+ Assert.Contains(ollama.PlanningMessages[0], message =>
+ message.Role == "system" && message.Content.Contains("one implementation-and-verification Task", StringComparison.Ordinal));
Assert.Contains(ollama.PlanningMessages[1], message =>
message.Role == "system" && message.Content.Contains("Do not call or execute any Tool", StringComparison.Ordinal));
}
@@ -615,7 +619,14 @@ public sealed class PromptAndRuntimeTests
Temperatures.Add(temperatureOverride);
PlanningMessages.Add(messages);
string content = ThinkOverrides.Count == 2 && succeedOnRetry
- ? """{"tasks":[{"description":"analyze","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}"""
+ ? """
+ Here is the requested plan.
+
+ ```json
+ {"tasks":[{"description":"analyze","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}
+ ```
+ The JSON above is the complete plan.
+ """
: "not-json";
return Task.FromResult(new OllamaResult(content, 1, 1));
}