Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions evals/run-evals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 ]] \

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proves exactly two reads, both markers, and no shell fallback, but it does not prove the reads were issued in parallel or in the same tool batch. If the evidence claim is parallel composition, please assert that ordering/batch shape or narrow the claim to composability.

&& ! 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' \
Expand Down Expand Up @@ -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."

Expand Down
5 changes: 2 additions & 3 deletions feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 7 additions & 7 deletions openspec/changes/repair-agent-tool-boundaries/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 0 additions & 23 deletions src/Netclaw.Actors.Tests/Tools/GeneratedToolSchemaMetaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
197 changes: 0 additions & 197 deletions src/Netclaw.Actors.Tests/Tools/StructuredWorkspaceToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
// </copyright>
// -----------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
using Netclaw.Media;
Expand Down Expand Up @@ -156,202 +155,6 @@ await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 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(
Expand Down
Loading
Loading