diff --git a/evals/run-evals.sh b/evals/run-evals.sh index 2bbc7e201..3db7e953a 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -454,8 +454,6 @@ start_eval_daemon() { printf 'local-search-eval-token\n' > "$selection_root/search-target/nested/match.txt" printf 'batch-first-marker\n' > "$selection_root/batch-first.txt" printf 'batch-second-marker\n' > "$selection_root/batch-second.txt" - printf '{"status":"structured-json-ready","ignored":"not-requested"}\n' \ - > "$selection_root/status.json" printf '\211PNG\r\n\032\n\000\000\000\rIHDR\000\000\000\003\000\000\000\002' \ > "$selection_root/dimensions.png" @@ -1469,20 +1467,15 @@ assert_tool_local_repository_search() { && stdout_response_contains 'match.txt' } -assert_tool_known_batch_read() { - stdout_tool_called 'file_read_many' \ +assert_tool_known_composed_read() { + local read_count + read_count=$(grep -aoE '\[tool:call\] file_read\(' "$STDOUT_FILE" 2>/dev/null | wc -l | tr -d ' ' || true) + [[ "$read_count" -eq 2 ]] \ && ! stdout_tool_called 'shell_execute' \ && stdout_response_contains 'batch-first-marker' \ && stdout_response_contains 'batch-second-marker' } -assert_tool_known_json_projection() { - stdout_tool_called 'json_read' \ - && ! stdout_tool_called 'shell_execute' \ - && stdout_response_contains 'structured-json-ready' \ - && ! stdout_response_contains 'not-requested' -} - assert_tool_known_image_metadata() { stdout_tool_called 'file_read' \ && ! stdout_tool_called 'shell_execute' \ @@ -2696,12 +2689,9 @@ run_all() { run_case tool_local_repository_search "recursive literal search uses file_search without shell" \ "Search recursively under /home/netclaw/.netclaw/workspaces/file-tool-selection/search-target for the exact text local-search-eval-token and tell me which file contains it." - run_case tool_known_batch_read "known files use one bounded file_read_many call" \ + run_case tool_known_composed_read "known files use two bounded file_read calls" \ "Read /home/netclaw/.netclaw/workspaces/file-tool-selection/batch-first.txt and /home/netclaw/.netclaw/workspaces/file-tool-selection/batch-second.txt. Return both exact values." - run_case tool_known_json_projection "known JSON selection uses json_read without an interpreter" \ - "Read only the /status value from /home/netclaw/.netclaw/workspaces/file-tool-selection/status.json. Do not return other properties." - run_case tool_known_image_metadata "known image metadata uses file_read without an interpreter" \ "Report the exact dimensions of /home/netclaw/.netclaw/workspaces/file-tool-selection/dimensions.png." diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 39a765a24..8c2671df8 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.63.0" + version: "2.64.0" --- # Netclaw Operations @@ -42,9 +42,8 @@ a reference file — load the one matching the user's intent with When available, use `file_read` for a known local file read. When available, use `file_list` for a known local directory listing. Use `file_search` for bounded recursive name or literal text search. -Use `file_read_many` when the paths to read are already known. -Use `json_read` for bounded JSON pointer selection. Use `file_read` for image metadata. +Issue independent `file_read` calls in parallel when several paths are known. Use `tool_output_read` to continue a spilled result by call id. When available, use `file_write` or `file_edit` for a known local file change. When available, use `web_search` for external discovery and `web_fetch` for a known external page. diff --git a/openspec/changes/make-agent-tools-pit-of-success/evidence/tool-friction-fixtures.json b/openspec/changes/make-agent-tools-pit-of-success/evidence/tool-friction-fixtures.json index ce2c4e98c..d765825b7 100644 --- a/openspec/changes/make-agent-tools-pit-of-success/evidence/tool-friction-fixtures.json +++ b/openspec/changes/make-agent-tools-pit-of-success/evidence/tool-friction-fixtures.json @@ -29,24 +29,14 @@ }, { "id": "TF02", - "scenario": "BatchRead", + "scenario": "ComposedRead", "observedFriction": "ApprovalGatedShellBatch", - "expectedToolSequence": ["file_read_many"], + "expectedToolSequence": ["file_read", "file_read"], "expectedOutcome": "success", "expectedApprovalRequired": false, "fallbackApprovalRequired": true, "expectedContextEffect": "RecordTwoCanonicalFiles" }, - { - "id": "TF03", - "scenario": "JsonProjection", - "observedFriction": "ApprovalGatedInterpreterProjection", - "expectedToolSequence": ["json_read"], - "expectedOutcome": "success", - "expectedApprovalRequired": false, - "fallbackApprovalRequired": true, - "expectedContextEffect": "RecordOneCanonicalFile" - }, { "id": "TF04", "scenario": "ImageMetadata", diff --git a/openspec/changes/repair-agent-tool-boundaries/tasks.md b/openspec/changes/repair-agent-tool-boundaries/tasks.md index e99152e8c..81ea36dff 100644 --- a/openspec/changes/repair-agent-tool-boundaries/tasks.md +++ b/openspec/changes/repair-agent-tool-boundaries/tasks.md @@ -11,13 +11,13 @@ ## 2. Remove bulk tools -- [ ] 2.1 Remove `JsonReadTool`, `FileReadManyTool`, their schemas, and their registration paths. -- [ ] 2.2 Remove both tool names from audience profiles, core snapshots, indexes, prompts, and system skills. -- [ ] 2.3 Replace valid batch intent with parallel bounded `file_read` coverage. -- [ ] 2.4 Remove JSON projection product fixtures and update all evidence digests. -- [ ] 2.5 Update tool footprint evidence and schema snapshots for the reduced core. -- [ ] 2.6 Remove or replace the two tool-specific eval scenarios. -- [ ] 2.7 Run focused tool, actor, schema, fixture, header, and Slopwatch gates. +- [x] 2.1 Remove `JsonReadTool`, `FileReadManyTool`, their schemas, and their registration paths. +- [x] 2.2 Remove both tool names from audience profiles, core snapshots, indexes, prompts, and system skills. +- [x] 2.3 Replace valid batch intent with parallel bounded `file_read` coverage. +- [x] 2.4 Remove JSON projection product fixtures and update all evidence digests. +- [x] 2.5 Update tool footprint evidence and schema snapshots for the reduced core. +- [x] 2.6 Remove or replace the two tool-specific eval scenarios. +- [x] 2.7 Run focused tool, actor, schema, fixture, header, and Slopwatch gates. ## 3. Repair rollout contracts diff --git a/src/Netclaw.Actors.Tests/Tools/GeneratedToolSchemaMetaTests.cs b/src/Netclaw.Actors.Tests/Tools/GeneratedToolSchemaMetaTests.cs index 556c3c59b..817f63cad 100644 --- a/src/Netclaw.Actors.Tests/Tools/GeneratedToolSchemaMetaTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/GeneratedToolSchemaMetaTests.cs @@ -89,29 +89,6 @@ public void SkillLoadSchemaDescribesPromptArgumentsAsStringMap() Assert.Equal("string", arguments.GetProperty("additionalProperties").GetProperty("type").GetString()); } - [Fact] - public void Generated_schema_describes_string_arrays_without_scalar_coercion() - { - var tool = new FileReadManyTool( - new ToolConfig(), - new NetclawPaths(), - new Netclaw.Security.ToolPathPolicy([])); - - var paths = tool.ParameterSchema - .GetProperty("properties") - .GetProperty("Paths"); - - Assert.Equal("array", paths.GetProperty("type").GetString()); - Assert.Equal("string", paths.GetProperty("items").GetProperty("type").GetString()); - Assert.Contains( - "Relative paths use the current project", - paths.GetProperty("description").GetString(), - StringComparison.Ordinal); - Assert.Contains( - "Paths", - tool.ParameterSchema.GetProperty("required").EnumerateArray().Select(item => item.GetString())); - } - [Fact] public void GeneratedDictionaryBinderSupportsAllDeclaredMapShapes() { diff --git a/src/Netclaw.Actors.Tests/Tools/StructuredWorkspaceToolTests.cs b/src/Netclaw.Actors.Tests/Tools/StructuredWorkspaceToolTests.cs index ccfe9f020..cc22c998b 100644 --- a/src/Netclaw.Actors.Tests/Tools/StructuredWorkspaceToolTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/StructuredWorkspaceToolTests.cs @@ -4,7 +4,6 @@ // // ----------------------------------------------------------------------- using System.Text; -using System.Text.Json; using Netclaw.Actors.Tools; using Netclaw.Configuration; using Netclaw.Media; @@ -156,202 +155,6 @@ await Assert.ThrowsAnyAsync(() => tool.ExecuteAsync( Assert.Null(context.Receipt); } - [Fact] - public async Task File_read_many_validates_denied_batch_before_returning_content() - { - var allowed = Path.Join(_project, "allowed.txt"); - var denied = Path.Join(_project, "denied.txt"); - await File.WriteAllTextAsync(allowed, "allowed-secret-marker", TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(denied, "denied-secret-marker", TestContext.Current.CancellationToken); - var context = CreateContext(); - var tool = new FileReadManyTool(_config, new NetclawPaths(), new ToolPathPolicy([denied])); - - var result = await tool.ExecuteAsync( - ToolInput.Create("Paths", new[] { "allowed.txt", "denied.txt" }), - context, - TestContext.Current.CancellationToken); - - Assert.DoesNotContain("allowed-secret-marker", result, StringComparison.Ordinal); - Assert.DoesNotContain("denied-secret-marker", result, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.AccessDenied, context.Receipt?.Category); - Assert.Empty(context.Receipt?.FileActivity ?? []); - } - - [Fact] - public async Task File_read_many_returns_bounded_labeled_sections_and_canonical_activity() - { - var first = Path.Join(_project, "a.txt"); - var second = Path.Join(_project, "b.txt"); - await File.WriteAllTextAsync(first, new string('a', 100), TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(second, new string('b', 100), TestContext.Current.CancellationToken); - var context = CreateContext(); - var tool = new FileReadManyTool(_config, new NetclawPaths(), _openPathPolicy); - var totalLimit = first.Length + second.Length + 80; - - var result = await tool.ExecuteAsync( - ToolInput.Create( - "Paths", new[] { "a.txt", "b.txt" }, - "MaxCharsPerFile", 20, - "MaxTotalChars", totalLimit), - context, - TestContext.Current.CancellationToken); - - Assert.True(result.Length <= totalLimit); - Assert.Contains($"== {first} ==", result, StringComparison.Ordinal); - Assert.Contains($"== {second} ==", result, StringComparison.Ordinal); - Assert.Contains("[truncated]", result, StringComparison.Ordinal); - Assert.Equal([first, second], context.Receipt!.FileActivity.Select(activity => activity.CanonicalPath)); - Assert.All(context.Receipt.FileActivity, activity => Assert.Equal(ToolFileActivityKind.Read, activity.Kind)); - } - - [Fact] - public async Task File_read_many_rejects_duplicate_and_non_text_members_atomically() - { - var text = Path.Join(_project, "text.txt"); - var binary = Path.Join(_project, "binary.bin"); - await File.WriteAllTextAsync(text, "must-not-leak", TestContext.Current.CancellationToken); - await File.WriteAllBytesAsync(binary, [0xFF, 0xFE, 0xFD], TestContext.Current.CancellationToken); - - var duplicateContext = CreateContext(); - var tool = new FileReadManyTool(_config, new NetclawPaths(), _openPathPolicy); - var duplicate = await tool.ExecuteAsync( - ToolInput.Create("Paths", new[] { "text.txt", "./text.txt" }), - duplicateContext, - TestContext.Current.CancellationToken); - Assert.Contains("duplicate", duplicate, StringComparison.OrdinalIgnoreCase); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, duplicateContext.Receipt?.Category); - - var binaryContext = CreateContext(); - var binaryResult = await tool.ExecuteAsync( - ToolInput.Create("Paths", new[] { "text.txt", "binary.bin" }), - binaryContext, - TestContext.Current.CancellationToken); - Assert.DoesNotContain("must-not-leak", binaryResult, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, binaryContext.Receipt?.Category); - Assert.Empty(binaryContext.Receipt?.FileActivity ?? []); - } - - [Fact] - public async Task File_read_many_rejects_path_count_above_the_hard_ceiling() - { - var context = CreateContext(); - var tool = new FileReadManyTool(_config, new NetclawPaths(), _openPathPolicy); - - var result = await tool.ExecuteAsync( - ToolInput.Create("Paths", Enumerable.Range(0, FileReadManyTool.MaximumPathCount + 1) - .Select(index => $"{index}.txt") - .ToArray()), - context, - TestContext.Current.CancellationToken); - - Assert.Contains($"between 1 and {FileReadManyTool.MaximumPathCount}", result, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, context.Receipt?.Category); - } - - [Fact] - public async Task Json_read_projects_rfc6901_pointers_without_executable_queries() - { - var path = Path.Join(_project, "status.json"); - await File.WriteAllTextAsync( - path, - """{"status":"ready","items":[{"name":"first"}],"a/b":{"~key":7}}""", - TestContext.Current.CancellationToken); - var context = CreateContext(); - var tool = new JsonReadTool(_config, new NetclawPaths(), _openPathPolicy); - - var result = await tool.ExecuteAsync( - ToolInput.Create( - "Path", "status.json", - "Pointers", new[] { "/status", "/items/0/name", "/a~1b/~0key" }), - context, - TestContext.Current.CancellationToken); - - using var projected = JsonDocument.Parse(result); - Assert.Equal("ready", projected.RootElement.GetProperty("/status").GetString()); - Assert.Equal("first", projected.RootElement.GetProperty("/items/0/name").GetString()); - Assert.Equal(7, projected.RootElement.GetProperty("/a~1b/~0key").GetInt32()); - Assert.Equal(path, Assert.Single(context.Receipt!.FileActivity).CanonicalPath); - } - - [Fact] - public async Task Json_read_rejects_invalid_or_duplicate_pointers_before_projection() - { - var path = Path.Join(_project, "status.json"); - await File.WriteAllTextAsync(path, """{"status":"must-not-project"}""", TestContext.Current.CancellationToken); - var tool = new JsonReadTool(_config, new NetclawPaths(), _openPathPolicy); - - var invalidContext = CreateContext(); - var invalid = await tool.ExecuteAsync( - ToolInput.Create("Path", "status.json", "Pointers", new[] { "/status", "status" }), - invalidContext, - TestContext.Current.CancellationToken); - Assert.DoesNotContain("must-not-project", invalid, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, invalidContext.Receipt?.Category); - - var duplicateContext = CreateContext(); - var duplicate = await tool.ExecuteAsync( - ToolInput.Create("Path", "status.json", "Pointers", new[] { "/status", "/status" }), - duplicateContext, - TestContext.Current.CancellationToken); - Assert.Contains("duplicate", duplicate, StringComparison.OrdinalIgnoreCase); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, duplicateContext.Receipt?.Category); - } - - [Fact] - public async Task Json_read_enforces_input_and_output_bounds_without_partial_values() - { - var path = Path.Join(_project, "large.json"); - await File.WriteAllTextAsync( - path, - JsonSerializer.Serialize(new { value = new string('x', 200) }), - TestContext.Current.CancellationToken); - var tool = new JsonReadTool(_config, new NetclawPaths(), _openPathPolicy); - - var inputContext = CreateContext(); - var input = await tool.ExecuteAsync( - ToolInput.Create("Path", "large.json", "Pointers", new[] { "/value" }, "MaxInputBytes", 10), - inputContext, - TestContext.Current.CancellationToken); - Assert.DoesNotContain(new string('x', 20), input, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, inputContext.Receipt?.Category); - - var outputContext = CreateContext(); - var output = await tool.ExecuteAsync( - ToolInput.Create("Path", "large.json", "Pointers", new[] { "/value" }, "MaxOutputChars", 20), - outputContext, - TestContext.Current.CancellationToken); - Assert.DoesNotContain(new string('x', 20), output, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, outputContext.Receipt?.Category); - } - - [Fact] - public async Task Json_read_rejects_malformed_json_and_pointer_count_above_the_ceiling() - { - var path = Path.Join(_project, "malformed.json"); - await File.WriteAllTextAsync(path, "{\"status\":", TestContext.Current.CancellationToken); - var tool = new JsonReadTool(_config, new NetclawPaths(), _openPathPolicy); - - var malformedContext = CreateContext(); - var malformed = await tool.ExecuteAsync( - ToolInput.Create("Path", "malformed.json", "Pointers", new[] { "/status" }), - malformedContext, - TestContext.Current.CancellationToken); - Assert.Contains("Invalid JSON", malformed, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, malformedContext.Receipt?.Category); - - var countContext = CreateContext(); - var count = await tool.ExecuteAsync( - ToolInput.Create( - "Path", "malformed.json", - "Pointers", Enumerable.Range(0, JsonReadTool.MaximumPointerCount + 1) - .Select(index => $"/{index}") - .ToArray()), - countContext, - TestContext.Current.CancellationToken); - Assert.Contains($"between 1 and {JsonReadTool.MaximumPointerCount}", count, StringComparison.Ordinal); - Assert.Equal(ToolInvocationOutcomeCategory.InvalidInput, countContext.Receipt?.Category); - } - [Theory] [MemberData(nameof(ImageHeaders))] public void Image_dimension_reader_handles_supported_bounded_headers( diff --git a/src/Netclaw.Actors.Tests/Tools/ToolFrictionReplayTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolFrictionReplayTests.cs index cdaa27d2d..04896f6a3 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolFrictionReplayTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolFrictionReplayTests.cs @@ -36,7 +36,6 @@ protected override void ConfigureAkka(Akka.Hosting.AkkaConfigurationBuilder buil [Theory] [InlineData("TF01")] [InlineData("TF02")] - [InlineData("TF03")] [InlineData("TF04")] [InlineData("TF05")] [InlineData("TF06")] @@ -225,8 +224,7 @@ private static async Task CreateScenarioAsync( return policyCase.Id switch { "TF01" => await RecursiveSearchAsync(project, session, seed, denied, cancellationToken), - "TF02" => await BatchReadAsync(project, session, seed, denied, cancellationToken), - "TF03" => await JsonProjectionAsync(project, session, seed, denied, cancellationToken), + "TF02" => await ComposedReadAsync(project, session, seed, denied, cancellationToken), "TF04" => await ImageMetadataAsync(project, session, seed, denied, cancellationToken), "TF05" => await SpillContinuationAsync(project, session, seed, denied, cancellationToken), "TF06" => FailedFileActivity(project, session, seed, denied), @@ -255,7 +253,7 @@ await File.WriteAllTextAsync( PythonFallback("search-fallback", project, "print('recursive search')")); } - private static async Task BatchReadAsync( + private static async Task ComposedReadAsync( string project, string session, string seed, @@ -271,30 +269,14 @@ private static async Task BatchReadAsync( session, seed, denied, - [Call("batch", FileReadManyTool.ToolName, "Paths", new[] { "first.txt", "second.txt" })], + [ + Call("read-first", FileReadTool.ToolName, "Path", "first.txt"), + Call("read-second", FileReadTool.ToolName, "Path", "second.txt") + ], PythonFallback("batch-fallback", project, "print(open('first.txt').read()); print(open('second.txt').read())"), [first, second]); } - private static async Task JsonProjectionAsync( - string project, - string session, - string seed, - string denied, - CancellationToken cancellationToken) - { - var path = Path.Join(project, "data.json"); - await File.WriteAllTextAsync(path, "{\"status\":\"ready\"}", cancellationToken); - return Setup( - project, - session, - seed, - denied, - [Call("json", JsonReadTool.ToolName, "Path", "data.json", "Pointers", new[] { "/status" })], - PythonFallback("json-fallback", project, "import json; print(json.load(open('data.json'))['status'])"), - [path]); - } - private static async Task ImageMetadataAsync( string project, string session, diff --git a/src/Netclaw.Actors.Tests/Tools/ToolRegistrationExtensionsTests.Core_tool_names_and_schema_footprint_match_snapshot.verified.txt b/src/Netclaw.Actors.Tests/Tools/ToolRegistrationExtensionsTests.Core_tool_names_and_schema_footprint_match_snapshot.verified.txt index 03d4017b0..854b8e272 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolRegistrationExtensionsTests.Core_tool_names_and_schema_footprint_match_snapshot.verified.txt +++ b/src/Netclaw.Actors.Tests/Tools/ToolRegistrationExtensionsTests.Core_tool_names_and_schema_footprint_match_snapshot.verified.txt @@ -3,10 +3,8 @@ file_edit, file_list, file_read, - file_read_many, file_search, file_write, - json_read, load_tool, search_tools, set_working_directory, @@ -16,7 +14,7 @@ tool_output_read ], Footprint: { - Count: 14, - SerializedDefinitionBytes: 17407 + Count: 12, + SerializedDefinitionBytes: 15215 } } \ No newline at end of file diff --git a/src/Netclaw.Actors/Tools/FileReadManyTool.cs b/src/Netclaw.Actors/Tools/FileReadManyTool.cs deleted file mode 100644 index 42774768b..000000000 --- a/src/Netclaw.Actors/Tools/FileReadManyTool.cs +++ /dev/null @@ -1,162 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.ComponentModel; -using System.Text; -using Netclaw.Configuration; -using Netclaw.Security; -using Netclaw.Tools; - -namespace Netclaw.Actors.Tools; - -[NetclawTool(ToolName, - "Read several known text files atomically without shell. Every path is authorized before any content is returned.", - Grant = "file")] -public sealed partial class FileReadManyTool : NetclawTool -{ - public const string ToolName = "file_read_many"; - internal const int MaximumPathCount = 32; - internal const int MaximumCharsPerFile = 128_000; - internal const int MaximumTotalChars = 256_000; - private const int DefaultCharsPerFile = 16_000; - private const int DefaultTotalChars = 64_000; - - private readonly ToolPathPolicy _pathPolicy; - private readonly ScopedFileAccessPolicy _fileAccessPolicy; - - public record Params( - [property: Description("File paths to read. Relative paths use the current project, then session scratch.")] - string[] Paths, - [property: Description("Maximum characters returned from each file (default 16000, maximum 128000).")] int? MaxCharsPerFile = null, - [property: Description("Maximum characters returned across the entire result (default 64000, maximum 256000).")] int? MaxTotalChars = null); - - public FileReadManyTool(ToolConfig config, NetclawPaths paths, ToolPathPolicy pathPolicy) - { - _pathPolicy = pathPolicy; - _fileAccessPolicy = new ScopedFileAccessPolicy(config, paths); - } - - protected override async Task ExecuteAsync(Params args, ToolInvocationContext context, CancellationToken ct) - { - if (args.Paths is not { Length: > 0 and <= MaximumPathCount }) - return context.InvalidInput($"Error: 'Paths' must contain between 1 and {MaximumPathCount} entries."); - - if (!WorkspaceFileToolSupport.TryResolveBound( - args.MaxCharsPerFile, - DefaultCharsPerFile, - MaximumCharsPerFile, - nameof(args.MaxCharsPerFile), - out var perFileLimit, - out var perFileError)) - { - return context.InvalidInput(perFileError); - } - - if (!WorkspaceFileToolSupport.TryResolveBound( - args.MaxTotalChars, - DefaultTotalChars, - MaximumTotalChars, - nameof(args.MaxTotalChars), - out var totalLimit, - out var totalError)) - { - return context.InvalidInput(totalError); - } - - var paths = new List(args.Paths.Length); - var uniquePaths = new HashSet( - OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); - foreach (var authoredPath in args.Paths) - { - if (string.IsNullOrWhiteSpace(authoredPath)) - return context.InvalidInput("Error: 'Paths' may not contain an empty path."); - - if (!_fileAccessPolicy.TryResolveReadPath( - authoredPath, - context, - out var path, - out var accessError, - out var resolutionFailure)) - { - return context.PathResolutionFailure(accessError, resolutionFailure); - } - - if (_pathPolicy.IsReadDenied(path)) - return context.AccessDenied(FileToolErrors.CredentialReadDenied(path)); - - if (!File.Exists(path)) - return context.NotFound($"Error: File not found: {path}"); - - if (!uniquePaths.Add(path)) - return context.InvalidInput($"Error: duplicate file path resolves to {path}."); - - paths.Add(path); - } - - var prefixes = paths - .Select((path, index) => $"{(index == 0 ? string.Empty : "\n")}== {path} ==\n") - .ToArray(); - var prefixChars = prefixes.Sum(static prefix => prefix.Length); - if (prefixChars + paths.Count > totalLimit) - { - return context.InvalidInput( - $"Error: 'MaxTotalChars' must leave room for {paths.Count} labeled file sections."); - } - - try - { - var result = new StringBuilder(Math.Min(totalLimit, prefixChars + perFileLimit * paths.Count)); - var remainingContentChars = totalLimit - prefixChars; - for (var index = 0; index < paths.Count; index++) - { - ct.ThrowIfCancellationRequested(); - var remainingFiles = paths.Count - index; - var contentLimit = Math.Min(perFileLimit, remainingContentChars / remainingFiles); - var read = await WorkspaceFileToolSupport.ReadUtf8CharsAsync(paths[index], contentLimit, ct); - var content = AddTruncationMarker(read, contentLimit); - - result.Append(prefixes[index]); - result.Append(content); - remainingContentChars -= content.Length; - } - - return context.SuccessFiles( - result.ToString(), - paths, - ToolFileActivityKind.Read); - } - catch (DecoderFallbackException) - { - return context.InvalidInput("Error: file_read_many accepts UTF-8 text files only."); - } - catch (UnauthorizedAccessException ex) - { - return context.AccessDenied($"Error: Permission denied: {ex.Message}"); - } - catch (FileNotFoundException ex) - { - return context.NotFound($"Error: File not found: {ex.FileName ?? ex.Message}"); - } - catch (DirectoryNotFoundException ex) - { - return context.NotFound($"Error: Directory not found: {ex.Message}"); - } - catch (IOException ex) - { - return context.TransientFailure($"Error reading files: {ex.Message}"); - } - } - - private static string AddTruncationMarker( - WorkspaceFileToolSupport.BoundedText read, - int maxChars) - { - const string marker = "\n[truncated]"; - if (!read.Truncated || maxChars < marker.Length) - return read.Content; - - return read.Content[..Math.Min(read.Content.Length, maxChars - marker.Length)] + marker; - } -} diff --git a/src/Netclaw.Actors/Tools/JsonReadTool.cs b/src/Netclaw.Actors/Tools/JsonReadTool.cs deleted file mode 100644 index 13f1289c5..000000000 --- a/src/Netclaw.Actors/Tools/JsonReadTool.cs +++ /dev/null @@ -1,296 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Buffers; -using System.ComponentModel; -using System.Globalization; -using System.Text; -using System.Text.Json; -using Netclaw.Configuration; -using Netclaw.Security; -using Netclaw.Tools; - -namespace Netclaw.Actors.Tools; - -[NetclawTool(ToolName, - "Read selected values from an authorized JSON file using bounded RFC 6901 JSON Pointers without shell or executable queries.", - Grant = "file")] -public sealed partial class JsonReadTool : NetclawTool -{ - public const string ToolName = "json_read"; - internal const int MaximumPointerCount = 32; - internal const int MaximumInputBytes = 4 * 1024 * 1024; - internal const int MaximumOutputChars = 256_000; - private const int DefaultInputBytes = 1024 * 1024; - private const int DefaultOutputChars = 64_000; - - private readonly ToolPathPolicy _pathPolicy; - private readonly ScopedFileAccessPolicy _fileAccessPolicy; - - public record Params( - [property: Description("JSON file path. Relative paths use the current project, then session scratch.")] string Path, - [property: Description("RFC 6901 JSON Pointers to project, for example /status or /items/0/name.")] string[] Pointers, - [property: Description("Maximum input bytes parsed (default 1048576, maximum 4194304).")] int? MaxInputBytes = null, - [property: Description("Maximum characters returned (default 64000, maximum 256000).")] int? MaxOutputChars = null); - - public JsonReadTool(ToolConfig config, NetclawPaths paths, ToolPathPolicy pathPolicy) - { - _pathPolicy = pathPolicy; - _fileAccessPolicy = new ScopedFileAccessPolicy(config, paths); - } - - protected override async Task ExecuteAsync(Params args, ToolInvocationContext context, CancellationToken ct) - { - if (!TryValidatePointers(args.Pointers, out var pointers, out var pointerError)) - return context.InvalidInput(pointerError); - - if (!WorkspaceFileToolSupport.TryResolveBound( - args.MaxInputBytes, - DefaultInputBytes, - MaximumInputBytes, - nameof(args.MaxInputBytes), - out var inputLimit, - out var inputError)) - { - return context.InvalidInput(inputError); - } - - if (!WorkspaceFileToolSupport.TryResolveBound( - args.MaxOutputChars, - DefaultOutputChars, - MaximumOutputChars, - nameof(args.MaxOutputChars), - out var outputLimit, - out var outputError)) - { - return context.InvalidInput(outputError); - } - - if (!_fileAccessPolicy.TryResolveReadPath( - args.Path, - context, - out var path, - out var accessError, - out var resolutionFailure)) - { - return context.PathResolutionFailure(accessError, resolutionFailure); - } - - if (_pathPolicy.IsReadDenied(path)) - return context.AccessDenied(FileToolErrors.CredentialReadDenied(path)); - - if (!File.Exists(path)) - return context.NotFound($"Error: File not found: {path}"); - - try - { - if (new FileInfo(path).Length > inputLimit) - return context.InvalidInput($"Error: JSON input exceeds the {inputLimit}-byte limit."); - - var (bytes, truncated) = await ReadBoundedBytesAsync(path, inputLimit, ct); - if (truncated) - return context.InvalidInput($"Error: JSON input exceeds the {inputLimit}-byte limit."); - - using var document = JsonDocument.Parse(bytes, new JsonDocumentOptions - { - AllowTrailingCommas = false, - CommentHandling = JsonCommentHandling.Disallow, - MaxDepth = 64 - }); - - var selected = new List<(string Pointer, JsonElement Value)>(pointers.Count); - foreach (var pointer in pointers) - { - if (!TryResolvePointer(document.RootElement, pointer.Tokens, out var value)) - return context.NotFound($"Error: JSON pointer not found: {pointer.Source}"); - selected.Add((pointer.Source, value)); - } - - var output = WriteProjection(selected); - if (output.Length > outputLimit) - { - return context.InvalidInput( - $"Error: projected JSON exceeds the {outputLimit}-character output limit. Request fewer pointers."); - } - - return context.SuccessFile(output, path, ToolFileActivityKind.Read); - } - catch (JsonException ex) - { - return context.InvalidInput($"Error: Invalid JSON: {ex.Message}"); - } - catch (UnauthorizedAccessException) - { - return context.AccessDenied($"Error: Permission denied: {path}"); - } - catch (FileNotFoundException) - { - return context.NotFound($"Error: File not found: {path}"); - } - catch (DirectoryNotFoundException) - { - return context.NotFound($"Error: File not found: {path}"); - } - catch (IOException ex) - { - return context.TransientFailure($"Error reading JSON: {ex.Message}"); - } - } - - private static bool TryValidatePointers( - string[]? authoredPointers, - out IReadOnlyList pointers, - out string error) - { - pointers = []; - if (authoredPointers is not { Length: > 0 and <= MaximumPointerCount }) - { - error = $"Error: 'Pointers' must contain between 1 and {MaximumPointerCount} entries."; - return false; - } - - var unique = new HashSet(StringComparer.Ordinal); - var validated = new List(authoredPointers.Length); - foreach (var source in authoredPointers) - { - if (source is null || source.Any(char.IsControl) || !unique.Add(source)) - { - error = $"Error: duplicate or invalid JSON pointer: {source ?? ""}"; - return false; - } - - if (!TryParsePointer(source, out var pointer)) - { - error = $"Error: invalid RFC 6901 JSON pointer: {source}"; - return false; - } - - validated.Add(pointer); - } - - pointers = validated; - error = string.Empty; - return true; - } - - private static bool TryParsePointer(string source, out JsonPointer pointer) - { - if (source.Length == 0) - { - pointer = new JsonPointer(source, []); - return true; - } - - if (source[0] != '/') - { - pointer = default; - return false; - } - - var tokens = new List(); - var token = new StringBuilder(); - foreach (var encodedToken in source[1..].Split('/')) - { - token.Clear(); - token.EnsureCapacity(encodedToken.Length); - for (var index = 0; index < encodedToken.Length; index++) - { - if (encodedToken[index] != '~') - { - token.Append(encodedToken[index]); - continue; - } - - if (++index >= encodedToken.Length || encodedToken[index] is not ('0' or '1')) - { - pointer = default; - return false; - } - - token.Append(encodedToken[index] == '0' ? '~' : '/'); - } - tokens.Add(token.ToString()); - } - - pointer = new JsonPointer(source, tokens); - return true; - } - - private static bool TryResolvePointer( - JsonElement root, - IReadOnlyList tokens, - out JsonElement value) - { - value = root; - foreach (var token in tokens) - { - if (value.ValueKind == JsonValueKind.Object) - { - if (!value.TryGetProperty(token, out value)) - return false; - continue; - } - - if (value.ValueKind != JsonValueKind.Array - || token.Length == 0 - || token.Length > 1 && token[0] == '0' - || !int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index) - || index < 0 - || index >= value.GetArrayLength()) - { - return false; - } - - value = value[index]; - } - - return true; - } - - private static string WriteProjection(IReadOnlyList<(string Pointer, JsonElement Value)> selected) - { - var buffer = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - foreach (var (pointer, value) in selected) - { - writer.WritePropertyName(pointer); - value.WriteTo(writer); - } - writer.WriteEndObject(); - } - - return Encoding.UTF8.GetString(buffer.WrittenSpan); - } - - private static async Task<(ReadOnlyMemory Bytes, bool Truncated)> ReadBoundedBytesAsync( - string path, - int maxBytes, - CancellationToken ct) - { - var buffer = new byte[maxBytes + 1]; - var count = 0; - await using var stream = new FileStream( - path, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: 4096, - useAsync: true); - while (count < buffer.Length) - { - var read = await stream.ReadAsync(buffer.AsMemory(count, buffer.Length - count), ct); - if (read == 0) - break; - count += read; - } - - var truncated = count > maxBytes; - return (buffer.AsMemory(0, Math.Min(count, maxBytes)), truncated); - } - - private readonly record struct JsonPointer(string Source, IReadOnlyList Tokens); -} diff --git a/src/Netclaw.Actors/Tools/ToolChoiceGuidance.cs b/src/Netclaw.Actors/Tools/ToolChoiceGuidance.cs index 53765d668..1523e0f6a 100644 --- a/src/Netclaw.Actors/Tools/ToolChoiceGuidance.cs +++ b/src/Netclaw.Actors/Tools/ToolChoiceGuidance.cs @@ -11,12 +11,11 @@ internal static class ToolChoiceGuidance public const string StructuredWorkspaceSelection = """ Prefer structured workspace tools: 1. Use file_search for bounded recursive name or literal text search. - 2. Use file_read_many when the paths to read are already known. - 3. Use json_read for bounded JSON pointer selection. - 4. Use file_read for file content and image metadata. - 5. Use tool_output_read to continue a spilled result by call id. - 6. Use search_tools, then load_tool, before reporting that a specialty tool is unavailable. - 7. Use shell or an interpreter only when no structured tool expresses the operation. + 2. Use file_read for bounded file content and image metadata. + 3. Issue independent file_read calls in parallel when several paths are known. + 4. Use tool_output_read to continue a spilled result by call id. + 5. Use search_tools, then load_tool, before reporting that a specialty tool is unavailable. + 6. Use shell or an interpreter only when no structured tool expresses the operation. """; public const string DirectorySelectionOrder = """ diff --git a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs index 1eac8e748..6604c6725 100644 --- a/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs +++ b/src/Netclaw.Actors/Tools/ToolRegistrationExtensions.cs @@ -41,8 +41,6 @@ public static ToolRegistry WithFirstPartyTools( registry.RegisterCore(new FileReadTool(config, paths, pathPolicy)); registry.RegisterCore(new FileListTool(config, paths, pathPolicy)); registry.RegisterCore(new FileSearchTool(config, paths, pathPolicy)); - registry.RegisterCore(new FileReadManyTool(config, paths, pathPolicy)); - registry.RegisterCore(new JsonReadTool(config, paths, pathPolicy)); registry.RegisterCore(new ToolOutputReadTool()); registry.RegisterCore(new FileWriteTool(config, paths, pathPolicy)); registry.RegisterCore(new FileEditTool(config, paths, pathPolicy)); diff --git a/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs b/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs index 19578f978..1bd5bbe66 100644 --- a/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs +++ b/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs @@ -22,8 +22,6 @@ public sealed class ToolAudienceProfileDefaultsTests ToolAudienceProfileToolCatalog.FileRead, ToolAudienceProfileToolCatalog.FileList, ToolAudienceProfileToolCatalog.FileSearch, - ToolAudienceProfileToolCatalog.FileReadMany, - ToolAudienceProfileToolCatalog.JsonRead, ToolAudienceProfileToolCatalog.ToolOutputRead, ToolAudienceProfileToolCatalog.AttachFile ] @@ -34,8 +32,6 @@ public sealed class ToolAudienceProfileDefaultsTests ToolAudienceProfileToolCatalog.FileRead, ToolAudienceProfileToolCatalog.FileList, ToolAudienceProfileToolCatalog.FileSearch, - ToolAudienceProfileToolCatalog.FileReadMany, - ToolAudienceProfileToolCatalog.JsonRead, ToolAudienceProfileToolCatalog.ToolOutputRead, ToolAudienceProfileToolCatalog.FileWrite, ToolAudienceProfileToolCatalog.FileEdit, diff --git a/src/Netclaw.Configuration/Resources/AGENTS.md b/src/Netclaw.Configuration/Resources/AGENTS.md index c54632847..f5d2d33c5 100644 --- a/src/Netclaw.Configuration/Resources/AGENTS.md +++ b/src/Netclaw.Configuration/Resources/AGENTS.md @@ -22,9 +22,8 @@ - When available, use `file_read` for a known local file read. - When available, use `file_list` for a known local directory listing. - Use `file_search` for bounded recursive name or literal text search. -- Use `file_read_many` when the paths to read are already known. -- Use `json_read` for bounded JSON pointer selection. - Use `file_read` for image metadata. +- Issue independent `file_read` calls in parallel when several paths are known. - Use `tool_output_read` to continue a spilled result by call id. - When available, use `file_write` or `file_edit` for a known local file change. - When available, use `web_search` for external discovery and `web_fetch` for a known external page. diff --git a/src/Netclaw.Configuration/Resources/AGENTS.public.md b/src/Netclaw.Configuration/Resources/AGENTS.public.md index 2f899942f..33662b95c 100644 --- a/src/Netclaw.Configuration/Resources/AGENTS.public.md +++ b/src/Netclaw.Configuration/Resources/AGENTS.public.md @@ -24,9 +24,8 @@ ## Structured Tool Selection - Use `file_search` for bounded recursive name or literal text search. -- Use `file_read_many` when the paths to read are already known. -- Use `json_read` for bounded JSON pointer selection. - Use `file_read` for file content and image metadata. +- Issue independent `file_read` calls in parallel when several paths are known. - Use `tool_output_read` to continue a spilled result by call id. - Use `search_tools`, then `load_tool`, before reporting that a specialty tool is unavailable. diff --git a/src/Netclaw.Configuration/ToolAudienceProfiles.cs b/src/Netclaw.Configuration/ToolAudienceProfiles.cs index 7a2f1dc1b..fa8aff868 100644 --- a/src/Netclaw.Configuration/ToolAudienceProfiles.cs +++ b/src/Netclaw.Configuration/ToolAudienceProfiles.cs @@ -133,8 +133,6 @@ public static class ToolAudienceProfileToolCatalog public const string FileRead = "file_read"; public const string FileList = "file_list"; public const string FileSearch = "file_search"; - public const string FileReadMany = "file_read_many"; - public const string JsonRead = "json_read"; public const string ToolOutputRead = "tool_output_read"; public const string AttachFile = "attach_file"; public const string FileWrite = "file_write"; @@ -152,7 +150,7 @@ public static class ToolAudienceProfileToolCatalog public const string SetWorkingDirectory = "set_working_directory"; public static IReadOnlyList FileTools { get; } = - [FileRead, FileList, FileSearch, FileReadMany, JsonRead, ToolOutputRead, FileWrite, FileEdit, AttachFile]; + [FileRead, FileList, FileSearch, ToolOutputRead, FileWrite, FileEdit, AttachFile]; public static IReadOnlyList WebTools { get; } = [WebSearch, WebFetch]; public static IReadOnlyList SkillTools { get; } = [SkillManage]; public static IReadOnlyList WebhookTools { get; } = [SetWebhook, ListWebhooks, DeleteWebhook]; @@ -160,7 +158,7 @@ public static class ToolAudienceProfileToolCatalog public static IReadOnlyList WorkingDirectoryTools { get; } = [SetWorkingDirectory]; public static IReadOnlyList PublicDefaultAllowedTools { get; } = - [FileRead, FileList, FileSearch, FileReadMany, JsonRead, ToolOutputRead, AttachFile]; + [FileRead, FileList, FileSearch, ToolOutputRead, AttachFile]; public static IReadOnlyList TeamDefaultAllowedTools { get; } = [ diff --git a/src/Netclaw.Security.Tests/ToolFrictionEvidenceContractTests.cs b/src/Netclaw.Security.Tests/ToolFrictionEvidenceContractTests.cs index 0ed15d03a..fe7d6c32d 100644 --- a/src/Netclaw.Security.Tests/ToolFrictionEvidenceContractTests.cs +++ b/src/Netclaw.Security.Tests/ToolFrictionEvidenceContractTests.cs @@ -15,7 +15,7 @@ public sealed partial class ToolFrictionEvidenceContractTests { private const string FixtureFile = "tool-friction-fixtures.json"; private const string FixtureSha256 = - "caac85054fd359eef9287b0babeea70e2f6830294e228109fb988942bbe6335c"; + "40ae6dbe3b9c6e9c8d061face39b341ee0124b517d63414353430f5ed7f3442c"; private static readonly string[] ProhibitedRawIdentifierClasses = [ @@ -36,10 +36,8 @@ public sealed partial class ToolFrictionEvidenceContractTests [ new("TF01", "RecursiveSearch", "ApprovalGatedShellSearch", ["file_search"], "success", false, true, "NoContextChangeRequired"), - new("TF02", "BatchRead", "ApprovalGatedShellBatch", ["file_read_many"], + new("TF02", "ComposedRead", "ApprovalGatedShellBatch", ["file_read", "file_read"], "success", false, true, "RecordTwoCanonicalFiles"), - new("TF03", "JsonProjection", "ApprovalGatedInterpreterProjection", ["json_read"], - "success", false, true, "RecordOneCanonicalFile"), new("TF04", "ImageMetadata", "ApprovalGatedInterpreterMetadata", ["file_read"], "success", false, true, "RecordOneCanonicalFile"), new("TF05", "SpillContinuation", "ApprovalGatedSpillParsing", ["tool_output_read"],