FohenAIBuilder file.write tool – 100% dog food

The first FoehnAIBuilder tools had to be a scan and file read tools so the LLM could understand the structure and functionality of a project. Then the ability to write a text file was next so it could generate application files.

// Copyright (c) August 2026, devMobile Software
// 
using FoehnAIBuilder.Abstractions;

namespace FoehnAI.Tools.WriteFile;

/// <summary>
/// Writes text content to a file, creating the file (and any missing parent
/// directories) if it doesn't already exist.
/// </summary>
public sealed class WriteFileTool : ITool
{
    private readonly ILogger<WriteFileTool> _logger;

    public WriteFileTool(ILogger<WriteFileTool> logger)
    {
        _logger = logger;
    }

    public string Name => "write_file";

    public string Description =>
        "Writes text content to a file at the given path, creating the file (and any missing " +
        "parent directories) if it doesn't already exist.";

    public string Command => """
        {
          "type": "object",
          "properties": {
            "path": { "type": "string", "description": "Path to the file to write." },
            "content": { "type": "string", "description": "Text content to write to the file." },
            "overwrite": { "type": "boolean", "description": "Whether to overwrite the file if it already exists. Defaults to true." }
          },
          "required": ["path", "content"]
        }
        """;

    public ToolRiskLevel RiskLevel => ToolRiskLevel.Write;

    public async Task<ToolExecutionResult> ExecuteAsync(string argumentsJson, CancellationToken cancellationToken = default)
    {
        if (!ToolArguments.TryParse(argumentsJson, WriteFileJsonContext.Default.WriteFileArguments, out var args, out var jsonError))
        {
            _logger.LogWarning("Failed to parse write_file arguments: {Arguments} ({Error})", argumentsJson, jsonError);
            return ToolExecutionResult.Fail(jsonError!);
        }

        var path = args.Path;
        var content = args.Content;
        var overwrite = args.Overwrite ?? true;

        if (!ToolPath.TryResolve(path, out var fullPath, out var pathError))
            return ToolExecutionResult.Fail(pathError!);

        if (!overwrite && File.Exists(fullPath))
            return ToolExecutionResult.Fail($"File already exists and overwrite is false: {path}");

        _logger.LogInformation("Writing {Length} characters to {Path}", content.Length, path);

        try
        {
            var directory = Path.GetDirectoryName(fullPath);
            if (!string.IsNullOrEmpty(directory))
                Directory.CreateDirectory(directory);

            await File.WriteAllTextAsync(fullPath, content, cancellationToken);
            return ToolExecutionResult.Ok($"Wrote {content.Length} characters to \"{path}\".");
        }
        catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
        {
            _logger.LogError(ex, "Error writing file {Path}", path);
            return ToolExecutionResult.Fail($"Error writing \"{path}\": {ex.Message}");
        }
    }
}

I hadn’t noticed the LLM checking to see whether the class1.cs file existed before. I’m also considering creating an another ToolRiskLevel “Trusted” for tools like dotnet.

Again, the markdown response for the review was huge

Review: WriteFileTool.cs

Clean, small, focused implementation. Same shape as the earlier version — most of my prior comments still apply. Highlights below, with a couple of new items.

Notable changes since last review

  • Name is now "write_file" (was "file.write"). Fine — but make sure it matches the naming convention used by sibling tools (files.search, read_file, etc.). Pick one: dot-namespaced (files.write) or snake_case (write_file) and apply everywhere.

Correctness / robustness

1. TOCTOU race on the overwrite check

if (!overwrite && File.Exists(fullPath)) ...
await File.WriteAllTextAsync(fullPath, content, cancellationToken);

Another process could create the file between check and write. Use FileMode.CreateNew for the strict guarantee:

var mode = overwrite ? FileMode.Create : FileMode.CreateNew;
await using var fs = new FileStream(fullPath, mode, FileAccess.Write, FileShare.None,
    bufferSize: 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var sw = new StreamWriter(fs); // UTF-8 no BOM
await sw.WriteAsync(content.AsMemory(), cancellationToken);

Then catch IOException when the file already exists to return the friendly message.

2. Exception filter still too narrow

Catches only UnauthorizedAccessException | IOException. Also possible:

  • NotSupportedException (invalid path characters / colon)
  • SecurityException
  • ArgumentException from Path.GetDirectoryName on malformed input

Add them, or ensure ToolPath.TryResolve guarantees they can’t occur.

3. File.WriteAllTextAsync is truly async only with the right stream options

The overload you use internally opens a FileStream without FileOptions.Asynchronous, so the write happens on a thread-pool thread doing sync I/O. If you build the FileStream yourself (as in #1), pass FileOptions.Asynchronous for real async I/O.

Schema

4. Command doesn’t declare defaults or forbid extras

Same finding as SearchFilesTool:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "path":      { "type": "string",  "minLength": 1, "description": "Path to the file to write." },
    "content":   { "type": "string",  "description": "Text content to write to the file." },
    "overwrite": { "type": "boolean", "default": true, "description": "Whether to overwrite the file if it already exists." }
  },
  "required": ["path", "content"]
}

default: true matches your args.Overwrite ?? true fallback and stops the LLM from guessing.

Consistency with SearchFilesTool

5. ToolPath.TryResolve signature differs

Here you call:

ToolPath.TryResolve(path, out var fullPath, out var pathError)

SearchFilesTool calls the 4-arg overload with an explicit sandboxRoot:

ToolPath.TryResolve(sandboxRoot, path, out var fullPath, out var pathError)

If both resolve against Directory.GetCurrentDirectory(), no functional difference — but the inconsistency is a code-review smell. Prefer the explicit-sandbox overload everywhere so escape-from-sandbox behavior is provable per tool.

Observability / safety

6. Logging argumentsJson on parse failure could leak content

logger.LogWarning("Failed to parse write_file arguments: {Arguments} ({Error})", argumentsJson, jsonError);

content may contain secrets/PII. Log only length + jsonError, or redact.

7. content.Length = UTF-16 chars, not bytes

The success message says "Wrote {N} characters". Accurate but not a byte count. If callers expect bytes, use Encoding.UTF8.GetByteCount(content).

8. Cancellation before sync work

Directory.CreateDirectory is synchronous and not cancellable. Add:

cancellationToken.ThrowIfCancellationRequested();

just before it — cheap fast-fail.

Style nits

  • Mixed indentation: primary constructor line uses 3-space indent ( public string Name), the rest 4 spaces. Run a format pass.
  • Consider making Description and the schema static readonly fields instead of expression-bodied properties allocating the same strings per call (micro-optimization, only matters if Command is read on every LLM request).

Suggested consolidated write block

try
{
    cancellationToken.ThrowIfCancellationRequested();

    var directory = Path.GetDirectoryName(fullPath);
    if (!string.IsNullOrEmpty(directory))
        Directory.CreateDirectory(directory);

    var mode = overwrite ? FileMode.Create : FileMode.CreateNew;
    await using (var fs = new FileStream(
        fullPath, mode, FileAccess.Write, FileShare.None,
        bufferSize: 4096, FileOptions.Asynchronous))
    await using (var sw = new StreamWriter(fs)) // UTF-8 no BOM
    {
        await sw.WriteAsync(content.AsMemory(), cancellationToken);
    }

    return ToolExecutionResult.Ok($"Wrote {content.Length} characters to \"{path}\".");
}
catch (IOException) when (!overwrite && File.Exists(fullPath))
{
    return ToolExecutionResult.Fail($"File already exists and overwrite is false: {path}");
}
catch (Exception ex) when (ex is UnauthorizedAccessException
                              or IOException
                              or NotSupportedException
                              or System.Security.SecurityException)
{
    logger.LogError(ex, "Error writing file {Path}", path);
    return ToolExecutionResult.Fail($"Error writing \"{path}\": {ex.Message}");
}

Priority order

  1. Schema: additionalProperties:false + default:true — tiny change, prevents LLM confusion.
  2. FileMode.CreateNew for overwrite=false — closes real race condition.
  3. Broaden exception filterNotSupportedException, SecurityException.
  4. Real async I/O via explicit FileStream — only matters for large writes.
  5. Consistent ToolPath.TryResolve signature across tools.

Want me to produce the fully rewritten file?

The review was ok for throw away PoC so the only change I made was modifying the class to use a primary constructor. In a future version I’m considering loading the commandText from a file so it can be “tweaked” without requiring recompilation.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.