Skip to content
Merged
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
12 changes: 6 additions & 6 deletions openspec/changes/make-agent-tools-pit-of-success/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@

## 5. PR 5 - Structured workspace primitives

- [ ] 5.1 Implement bounded file_search with literal name/content modes, scoped root authorization, deterministic ordering, and no directory-symlink traversal.
- [ ] 5.2 Implement atomic bounded file_read_many with complete prevalidation, per-file ceilings, total ceiling, and canonical successful activity.
- [ ] 5.3 Implement bounded json_read using System.Text.Json and RFC 6901 pointers with atomic pointer validation.
- [ ] 5.4 Extend file_read image inspection with bounded PNG/JPEG/GIF/WebP dimensions and malformed-header fail-closed behavior.
- [ ] 5.5 Register only the small structured workspace schemas as Core and update compact capability hints.
- [ ] 5.6 Add count/byte/result ceilings, cancellation, access-denial, symlink, binary, encoding, malformed JSON, and cross-platform tests.
- [x] 5.1 Implement bounded file_search with literal name/content modes, scoped root authorization, deterministic ordering, and no directory-symlink traversal.
- [x] 5.2 Implement atomic bounded file_read_many with complete prevalidation, per-file ceilings, total ceiling, and canonical successful activity.
- [x] 5.3 Implement bounded json_read using System.Text.Json and RFC 6901 pointers with atomic pointer validation.
- [x] 5.4 Extend file_read image inspection with bounded PNG/JPEG/GIF/WebP dimensions and malformed-header fail-closed behavior.
- [x] 5.5 Register only the small structured workspace schemas as Core and update compact capability hints.
- [x] 5.6 Add count/byte/result ceilings, cancellation, access-denial, symlink, binary, encoding, malformed JSON, and cross-platform tests.

## 6. PR 6 - Spill continuation and conditional schemas

Expand Down
3 changes: 2 additions & 1 deletion src/Netclaw.Actors.Tests/Tools/FileReadToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,8 @@ private ToolExecutionContext CreatePublicContext()
private static readonly byte[] FakePngBytes =
[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01
];

private static readonly byte[] FakePdfBytes = "%PDF-1.7\nfake body\n%%EOF"u8.ToArray();
Expand Down
19 changes: 19 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/GeneratedToolSchemaMetaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ 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(
"Paths",
tool.ParameterSchema.GetProperty("required").EnumerateArray().Select(item => item.GetString()));
}

[Fact]
public void GeneratedDictionaryBinderSupportsAllDeclaredMapShapes()
{
Expand Down
491 changes: 491 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/StructuredWorkspaceToolTests.cs

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ToolArgumentHelperStrictTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,36 @@ public void StringDictionary_rejects_non_string_values()

Assert.Contains("Arguments.monthsBack", error.Message);
}

[Fact]
public void StringArray_reads_json_and_clr_arrays_without_coercion()
{
var pointers = ToolArgumentHelper.GetStringArray(
Args("Pointers", Json("""["/status","/items/0/name"]""")),
"Pointers");
var paths = ToolArgumentHelper.GetStringArray(
Args("Paths", new[] { "a.txt", "b.txt" }),
"Paths");

Assert.NotNull(pointers);
Assert.Equal(
["/status", "/items/0/name"],
pointers);
Assert.NotNull(paths);
Assert.Equal(
["a.txt", "b.txt"],
paths);
}

[Fact]
public void StringArray_rejects_scalar_and_non_string_members()
{
Assert.Throws<ArgumentException>(() => ToolArgumentHelper.GetStringArray(
Args("Paths", "a.txt"),
"Paths"));
var error = Assert.Throws<ArgumentException>(() => ToolArgumentHelper.GetStringArray(
Args("Paths", Json("""["a.txt",1]""")),
"Paths"));
Assert.Contains("Paths[1]", error.Message, StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
file_edit,
file_list,
file_read,
file_read_many,
file_search,
file_write,
json_read,
load_tool,
search_tools,
set_working_directory,
Expand All @@ -12,7 +15,7 @@
skill_read_resource
],
Footprint: {
Count: 10,
SerializedDefinitionBytes: 10879
Count: 13,
SerializedDefinitionBytes: 13466
}
}
162 changes: 162 additions & 0 deletions src/Netclaw.Actors/Tools/FileReadManyTool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// -----------------------------------------------------------------------
// <copyright file="FileReadManyTool.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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<FileReadManyTool.Params>
{
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<string> 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<string>(args.Paths.Length);
var uniquePaths = new HashSet<string>(
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;
}
}
57 changes: 50 additions & 7 deletions src/Netclaw.Actors/Tools/FileReadTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public sealed partial class FileReadTool : NetclawTool<FileReadTool.Params>
{
public const string ToolName = "file_read";
private const long MaxModelInputFileBytes = ChannelAttachmentPolicy.DefaultMaxFileBytes;
private const int MaxInspectionBytes = 64 * 1024;
private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
private static readonly Encoding StrictUtf16Le = new UnicodeEncoding(bigEndian: false, byteOrderMark: true, throwOnInvalidBytes: true);
private static readonly Encoding StrictUtf16Be = new UnicodeEncoding(bigEndian: true, byteOrderMark: true, throwOnInvalidBytes: true);
Expand Down Expand Up @@ -95,10 +96,20 @@ protected override async Task<string> ExecuteAsync(Params args, ToolInvocationCo
{
var inspection = await InspectFileAsync(authorizedPath, ct);
if (!inspection.IsTextLike)
{
if (inspection.ImageDimensionStatus == ImageDimensionStatus.Invalid)
{
return context.InvalidInput(BuildMetadataResponse(
authorizedPath,
inspection,
"Image header is malformed or its dimensions exceed supported bounds. Raw binary output is not returned by file_read."));
}

return context.SuccessFile(
HandleNonTextFile(authorizedPath, inspection, context),
authorizedPath,
ToolFileActivityKind.Read);
}

var encoding = inspection.TextEncoding ?? StrictUtf8;
if (startLine.HasValue || limit.HasValue)
Expand Down Expand Up @@ -126,7 +137,15 @@ protected override async Task<string> ExecuteAsync(Params args, ToolInvocationCo
return context.SuccessFile(
BuildMetadataResponse(
authorizedPath,
new FileInspection(MimeType.Default, AttachmentCategory.Other, sizeBytes, false, null),
new FileInspection(
MimeType.Default,
AttachmentCategory.Other,
sizeBytes,
false,
null,
ImageDimensionStatus.NotSupported,
null,
null),
"File is not valid in the detected text encoding. Raw binary output is not returned by file_read."),
authorizedPath,
ToolFileActivityKind.Read);
Expand All @@ -153,9 +172,17 @@ private static async Task<FileInspection> InspectFileAsync(string path, Cancella
{
var info = new FileInfo(path);
if (info.Length == 0)
return new FileInspection(new MimeType(MimeTypeCatalog.TextPlain), AttachmentCategory.Document, 0, true, StrictUtf8);

var sampleLength = (int)Math.Min(info.Length, 4096);
return new FileInspection(
new MimeType(MimeTypeCatalog.TextPlain),
AttachmentCategory.Document,
0,
true,
StrictUtf8,
ImageDimensionStatus.NotSupported,
null,
null);

var sampleLength = (int)Math.Min(info.Length, MaxInspectionBytes);
var buffer = new byte[sampleLength];
await using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
Expand All @@ -171,8 +198,17 @@ private static async Task<FileInspection> InspectFileAsync(string path, Cancella
var mimeType = ResolveMimeType(path, magicMime, extensionMime, textEncoding);
var category = MimeTypeCatalog.GetCategory(mimeType);
var isTextLike = looksText && MimeTypeCatalog.IsText(mimeType);

return new FileInspection(mimeType, category, info.Length, isTextLike, isTextLike ? textEncoding : null);
var dimensionStatus = ImageDimensionReader.Read(mimeType, buffer, out var dimensions);

return new FileInspection(
mimeType,
category,
info.Length,
isTextLike,
isTextLike ? textEncoding : null,
dimensionStatus,
dimensionStatus == ImageDimensionStatus.Valid ? dimensions.Width : null,
dimensionStatus == ImageDimensionStatus.Valid ? dimensions.Height : null);
}

private static MimeType ResolveMimeType(
Expand Down Expand Up @@ -259,10 +295,14 @@ private static string BuildMetadataResponse(
FileInspection inspection,
string guidance)
{
var dimensions = inspection is { Width: { } width, Height: { } height }
? $"Dimensions: {width}x{height}\n"
: string.Empty;
return $"File is not readable as plain text.\n" +
$"Path: {path}\n" +
$"Type: {inspection.MimeType} ({inspection.Category})\n" +
$"Size: {ByteSizeFormatter.Format(inspection.SizeBytes)}\n" +
dimensions +
guidance;
}

Expand Down Expand Up @@ -516,7 +556,10 @@ private sealed record FileInspection(
AttachmentCategory Category,
long SizeBytes,
bool IsTextLike,
Encoding? TextEncoding);
Encoding? TextEncoding,
ImageDimensionStatus ImageDimensionStatus,
int? Width,
int? Height);

private static long TryGetFileLength(string path)
{
Expand Down
Loading
Loading