Skip to content

Commit 4424efc

Browse files
committed
Fix empty metadata tool results and UTF-8 read boundaries
1 parent 6c2108d commit 4424efc

14 files changed

Lines changed: 271 additions & 26 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to ManagedCode.FileContext are documented here.
44

5+
## Unreleased
6+
7+
- Preserve UTF-8 byte accounting when a surrogate pair crosses a full-read buffer boundary.
8+
- Return a missing-file failure from the metadata tool so session restoration cannot turn a null result into empty content; keep the nullable direct API unchanged.
9+
- Verify tool-result ordering, empty/error outputs, restored-session follow-ups, and concurrent multi-file operations through real filesystem and LlmTck tests.
10+
- Update Meziantou.Analyzer to 3.0.203. Retain OpenAI 2.12.0 because Microsoft.Extensions.AI.OpenAI 10.9.0 requires OpenAI below 2.13.0.
11+
512
## 0.0.2 - 2026-09-03
613

714
- Enforce a 95% product line-coverage gate in local and CI coverage runs.

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
55
</PropertyGroup>
66
<ItemGroup>
7-
<GlobalPackageReference Include="Meziantou.Analyzer" Version="3.0.200" PrivateAssets="all" />
7+
<GlobalPackageReference Include="Meziantou.Analyzer" Version="3.0.203" PrivateAssets="all" />
88
<GlobalPackageReference Include="Roslynator.Analyzers" Version="5.0.0" PrivateAssets="all" />
99
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.33.0.1635" PrivateAssets="all" />
1010
</ItemGroup>

docs/Features/file-context.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ In scope: standard file access, bounded line navigation, metadata, Markdown grap
1313
3. The adapter implements the complete Agent Framework `AgentFileStore` contract using only `IStorage`.
1414
4. Standard tools come from Agent Framework `FileAccessProvider`: read, list, grep, write, delete, replace, and replace-lines.
1515
5. Write tools are disabled by default. When enabled, Agent Framework approval remains required unless the host explicitly disables it.
16-
6. Full reads fail before allocation when metadata exceeds `MaximumFullReadBytes`.
16+
6. Full reads fail before allocation when metadata exceeds `MaximumFullReadBytes`. Decoded UTF-8 limits retain encoder state across buffer boundaries, including split surrogate pairs.
1717
7. Range reads are 1-based by line, stream sequentially, return continuation metadata, and stop at configured line/byte limits.
1818
8. Content search is case-insensitive regular expression search with a timeout, file/match/result limits, and optional standard glob filtering.
1919
9. Directory listing returns direct child directories before direct child files and does not leak the configured storage prefix.
@@ -48,12 +48,30 @@ flowchart TD
4848
## Failure flows
4949

5050
- Unsafe paths fail with `ArgumentException`; the storage provider is not called.
51-
- Missing files return `null` through the `AgentFileStore` and metadata contracts; a range read throws `FileNotFoundException`.
51+
- Missing files return `null` through the direct `AgentFileStore` and `IFileContext.GetInfoAsync` contracts; range reads and the `file_context_info` tool throw `FileNotFoundException`.
5252
- Storage failures become `IOException` values with the operation and safe logical path, preserving the provider's safe problem detail.
5353
- Invalid or catastrophic regex patterns fail deterministically; a regex timeout does not hang the agent invocation.
5454
- Oversized files, result sets, or graph exports stop at configured boundaries and report truncation or a clear limit failure.
5555
- Graph operations with no matching Markdown input fail clearly instead of inventing an empty knowledge base.
5656

57+
## Empty results and conversation history
58+
59+
An empty file or no search matches is a valid tool outcome. With the tested Agent Framework function-invocation and OpenAI chat pipeline, these become a `role: tool` message with the matching `tool_call_id`: the content contains serialized `""` or `[]`, respectively. The metadata tool reports a missing file as a failure instead of returning `null`, because the tested Agent Framework session roundtrip converts a null function result into empty wire content. Range reads retain their structured window metadata even when their content is empty. Tool exceptions also produce a matching error result during the normal function-invocation loop.
60+
61+
FileContext does not persist agent sessions, synthesize fallback assistant responses, or repair interrupted model/tool turns. The host owns those concerns: it must preserve call/result pairs when saving or replaying history and handle cancellation, approval pauses, and provider failures before reusing an incomplete turn. A final assistant message does not replace a tool result. Completed tool turns are also tested through Agent Framework session serialization/restoration and a subsequent user request.
62+
63+
## Multiple files and concurrency
64+
65+
A model response can request several file tools, each with its own path and call ID. The integration tests verify that all results, including a failed sibling call, reach the next model request. Single-file read/write/range tools do not accept a batch of paths; list, grep, and Markdown graph operations already work across a scoped collection.
66+
67+
`FunctionInvokingChatClient` processes calls sequentially by default. A host using `UseFunctionInvocation` can enable concurrent execution:
68+
69+
```csharp
70+
.UseFunctionInvocation(configure: client => client.AllowConcurrentInvocation = true)
71+
```
72+
73+
Independent writes and range reads on eight different files are tested concurrently against the real filesystem provider. Other storage providers must support the host's chosen concurrency. FileContext adds no multi-file transaction or same-file write locking; serialize dependent operations and writes to the same path. Write-tool enablement and approval requirements still apply.
74+
5775
## Verification scenarios
5876

5977
1. Write, read, exists, list, grep, and delete through `ManagedCodeStorageFileStore` against real filesystem storage.

docs/Testing/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The suite is integration-first:
66
- graph tests run the real `ManagedCode.MarkdownLd.Kb` parser, graph builder, search, and serializers;
77
- dependency-injection tests resolve the actual default and keyed Agent Framework contracts;
88
- end-to-end tests run Agent Framework function invocation against a real LlmTck HTTP replay service and verify every advertised read, list, grep, write, delete, replace, range, metadata, graph-search, and graph-export tool;
9+
- protocol tests inspect outgoing HTTP messages for matching call/result IDs after empty results, missing files, tool failures, mutations, and multiple calls with sequential or concurrent invocation enabled, including a subsequent request after session serialization/restoration;
10+
- concurrent storage tests write and range-read eight independent files through one shared adapter/service;
911
- a sparse 1 GiB filesystem test reads bounded line windows repeatedly, rejects full-file loading, caps allocations, and proves that an oversized line fails before it can be buffered in memory.
1012

1113
Every filesystem test owns a unique temporary root and removes it on disposal. Test execution is serialized so process-wide allocation assertions cannot be distorted by another test. No `IStorage`, Agent Framework, Markdown-LD, or LlmTck mocks are used.

src/ManagedCode.FileContext/FileContextToolDescriptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ internal static class FileContextToolDescriptions
99
"Read a bounded, one-based line range from a text file. Use this instead of a full read for large files.";
1010
public const string StartLine = "One-based first line to read.";
1111
public const string LineCount = "Number of lines to return; omitted uses the configured default.";
12-
public const string GetInfo = "Return file size, media type, and last-modified time without reading its content.";
12+
public const string GetInfo = "Return file size, media type, and last-modified time without reading its content. Fails if the file does not exist.";
1313
public const string SearchMarkdownGraph =
1414
"Build a linked-data knowledge graph from scoped Markdown files and search its concepts and relationships.";
1515
public const string GraphQuery = "Concept or relationship query.";

src/ManagedCode.FileContext/FileContextTools.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ public Task<FileContextRange> ReadRangeAsync(
1515
}
1616

1717
[Description(FileContextToolDescriptions.GetInfo)]
18-
public Task<FileContextInfo?> GetInfoAsync(
18+
public async Task<FileContextInfo> GetInfoAsync(
1919
[Description(FileContextToolDescriptions.RelativeFilePath)] string path,
2020
CancellationToken cancellationToken = default)
2121
{
22-
return fileContext.GetInfoAsync(path, cancellationToken);
22+
return await fileContext.GetInfoAsync(path, cancellationToken).ConfigureAwait(false)
23+
?? throw new FileNotFoundException($"File '{path}' was not found.", path);
2324
}
2425

2526
[Description(FileContextToolDescriptions.SearchMarkdownGraph)]

src/ManagedCode.FileContext/StorageTextReader.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,29 @@ public static async Task<string> ReadAsync(
1414
{
1515
using var reader = new StreamReader(stream, Encoding.UTF8, true, ReaderBufferSize, leaveOpen: true);
1616
var buffer = new char[CharacterBufferLength];
17+
var encoder = Encoding.UTF8.GetEncoder();
18+
var encodedBuffer = new byte[Encoding.UTF8.GetMaxByteCount(CharacterBufferLength)];
1719
var builder = new StringBuilder();
1820
var bytesRead = 0L;
1921

2022
while (await reader.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false) is var read && read > 0)
2123
{
22-
bytesRead += Encoding.UTF8.GetByteCount(buffer.AsSpan(0, read));
23-
if (bytesRead > limit)
24-
{
25-
throw new IOException($"Decoded file content exceeds the configured {limit}-byte limit.");
26-
}
24+
bytesRead += encoder.GetBytes(buffer.AsSpan(0, read), encodedBuffer, flush: false);
25+
EnsureWithinLimit(bytesRead, limit);
2726

2827
builder.Append(buffer, 0, read);
2928
}
3029

30+
bytesRead += encoder.GetBytes(ReadOnlySpan<char>.Empty, encodedBuffer, flush: true);
31+
EnsureWithinLimit(bytesRead, limit);
3132
return builder.ToString();
3233
}
34+
35+
private static void EnsureWithinLimit(long bytesRead, long limit)
36+
{
37+
if (bytesRead > limit)
38+
{
39+
throw new IOException($"Decoded file content exceeds the configured {limit}-byte limit.");
40+
}
41+
}
3342
}

tests/ManagedCode.FileContext.Tests/FileContextBoundaryTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ public async Task FileQueries_WhenFileIsMissing_ReturnAbsenceOrClearFailure()
1212
await Should.ThrowAsync<FileNotFoundException>(() => service.ReadRangeAsync("missing.txt"));
1313
}
1414

15+
[Fact]
16+
public async Task MarkdownGraph_WhenSiblingDirectorySharesPrefix_ExcludesSiblingDocuments()
17+
{
18+
await using var scope = await TestStorageScope.CreateAsync();
19+
var store = new ManagedCodeStorageFileStore(scope.Storage);
20+
var service = new FileContextService(store);
21+
await store.WriteAsync("docs/included.md", "# Included document");
22+
await store.WriteAsync("docs-other/excluded.md", "# Excluded sibling");
23+
24+
var result = await service.ExportMarkdownGraphAsync(MarkdownGraphFormat.Mermaid, "docs");
25+
26+
result.DocumentCount.ShouldBe(1);
27+
result.Content.ShouldNotContain("Excluded sibling");
28+
}
29+
1530
[Fact]
1631
public async Task ReadRange_WhenArgumentsAreOutsideConfiguredBounds_RejectsRequest()
1732
{

tests/ManagedCode.FileContext.Tests/LlmTck/FileAccessMutationLlmTckTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ public async Task Agent_WhenLlmRequestsMutationTool_ChangesRealFileSystemContent
6060
(await store.ReadAsync(FileName)).ShouldBe(expectedContent);
6161
LlmTckToolReplay.RecordedRequests.Count.ShouldBe(2);
6262
LlmTckToolReplay.RecordedRequests[0].ShouldContain(toolName);
63+
var results = LlmTckToolAssertions.AssertClosedCalls(
64+
LlmTckToolReplay.RecordedRequests[1], $"call-{toolName}");
65+
results[0].GetProperty("content").GetString().ShouldNotBeNullOrWhiteSpace();
6366
var assertions = await host.GetAssertionsAsync();
6467
assertions.Matched.ShouldBe(2);
6568
assertions.Unmatched.ShouldBe(0);
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using System.ClientModel;
2+
using System.Text.Json;
3+
using ManagedCode.LlmTck.Configuration;
4+
using ManagedCode.LlmTck.Models;
5+
using Microsoft.Agents.AI;
6+
using Microsoft.Extensions.AI;
7+
using OpenAI;
8+
9+
namespace ManagedCode.FileContext.Tests.LlmTck;
10+
11+
public sealed class FileToolResultProtocolTests(Xunit.Abstractions.ITestOutputHelper output)
12+
{
13+
[Theory]
14+
[InlineData(FileAccessProvider.ReadFileToolName, "{\"fileName\":\"empty.txt\"}", "\"\"")]
15+
[InlineData(FileAccessProvider.ReadFileToolName, "{\"fileName\":\"missing.txt\"}", "not found")]
16+
[InlineData(FileAccessProvider.LsToolName, "{\"directory\":\"empty\"}", "[]")]
17+
[InlineData(FileAccessProvider.GrepToolName, "{\"regexPattern\":\"absent\",\"directory\":\"\"}", "[]")]
18+
[InlineData(FileContextToolNames.GetInfo, "{\"path\":\"missing.txt\"}", "Error: Function failed.")]
19+
[InlineData(FileContextToolNames.ReadRange, "{\"path\":\"empty.txt\"}", "\"content\": \"\"")]
20+
[InlineData(FileContextToolNames.ReadRange, "{\"path\":\"first.txt\",\"startLine\":100}", "\"content\": \"\"")]
21+
[InlineData(FileContextToolNames.ReadRange, "{\"path\":\"missing.txt\"}", "Error: Function failed.")]
22+
[InlineData(FileContextToolNames.ReadRange, "{\"path\":\"../escape.txt\"}", "Error: Function failed.")]
23+
[InlineData(FileContextToolNames.SearchMarkdownGraph, "{\"query\":\"absent\"}", "Error: Function failed.")]
24+
public async Task EmptyOrFailedTool_StillSendsMatchingResult(string toolName, string arguments, string expectedContent)
25+
{
26+
var requests = await RunToolLoopAsync(LlmTckToolReplay.CreateResponse("call-empty", toolName, arguments));
27+
28+
foreach (var request in requests)
29+
{
30+
var results = LlmTckToolAssertions.AssertClosedCalls(request, "call-empty");
31+
output.WriteLine(results[0].GetRawText());
32+
results[0].GetProperty("content").ValueKind.ShouldBe(JsonValueKind.String);
33+
results[0].GetProperty("content").GetString()!.ShouldContain(expectedContent);
34+
}
35+
}
36+
37+
[Theory]
38+
[InlineData(false)]
39+
[InlineData(true)]
40+
public async Task MultipleFileCalls_InOneTurn_ReturnEveryResult(bool concurrent)
41+
{
42+
var first = LlmTckToolReplay.CreateResponse("call-first", FileAccessProvider.ReadFileToolName, "{\"fileName\":\"first.txt\"}");
43+
var second = LlmTckToolReplay.CreateResponse("call-second", FileContextToolNames.ReadRange, "{\"path\":\"second.txt\"}");
44+
var missing = LlmTckToolReplay.CreateResponse("call-missing", FileContextToolNames.ReadRange, "{\"path\":\"missing.txt\"}");
45+
46+
var requests = await RunToolLoopAsync($"[{first},{second},{missing}]", concurrent);
47+
48+
foreach (var request in requests)
49+
{
50+
var results = LlmTckToolAssertions.AssertClosedCalls(request, "call-first", "call-second", "call-missing");
51+
results.Single(result => string.Equals(result.GetProperty("tool_call_id").GetString(), "call-first", StringComparison.Ordinal))
52+
.GetProperty("content").GetString()!.ShouldContain("first-content");
53+
results.Single(result => string.Equals(result.GetProperty("tool_call_id").GetString(), "call-second", StringComparison.Ordinal))
54+
.GetProperty("content").GetString()!.ShouldContain("second-content");
55+
results.Single(result => string.Equals(result.GetProperty("tool_call_id").GetString(), "call-missing", StringComparison.Ordinal))
56+
.GetProperty("content").GetString().ShouldNotBeNullOrWhiteSpace();
57+
}
58+
}
59+
60+
[Fact]
61+
public async Task LiteralNullAssistantText_IsNotInterpretedAsToolReplay()
62+
{
63+
var requests = await RunToolLoopAsync(
64+
LlmTckToolReplay.CreateResponse("read", FileAccessProvider.ReadFileToolName, "{\"fileName\":\"first.txt\"}"),
65+
finalResponse: "null");
66+
67+
foreach (var request in requests)
68+
{
69+
LlmTckToolAssertions.AssertClosedCalls(request, "read");
70+
}
71+
}
72+
73+
private static async Task<string[]> RunToolLoopAsync(string replay, bool concurrent = false, string finalResponse = "Inspection completed.")
74+
{
75+
const string prompt = "Inspect the files.";
76+
LlmTckToolReplay.Reset();
77+
var host = new LlmTckTestHost(() => new LlmTckConfigurationBuilder()
78+
.AddModel(FileContextAgentLlmTckTests.Model, LlmTckModelKind.Chat)
79+
.AddChatScenario("protocol", scenario => scenario.ForModel(FileContextAgentLlmTckTests.Model)
80+
.WhenUserContains(prompt).Responds(replay).Responds(finalResponse).Responds("Follow-up completed."))
81+
.Build());
82+
await using var hostLifetime = host.ConfigureAwait(false);
83+
await host.StartAsync().ConfigureAwait(false);
84+
var storage = await TestStorageScope.CreateAsync().ConfigureAwait(false);
85+
await using var storageLifetime = storage.ConfigureAwait(false);
86+
var store = new ManagedCodeStorageFileStore(storage.Storage);
87+
await store.WriteAsync("empty.txt", string.Empty).ConfigureAwait(false);
88+
await store.WriteAsync("first.txt", "first-content").ConfigureAwait(false);
89+
await store.WriteAsync("second.txt", "second-content").ConfigureAwait(false);
90+
using var provider = new FileContextProvider(store, new FileContextService(store),
91+
new FileContextOptions { RequireReadToolApproval = false });
92+
var sdk = new OpenAIClient(new ApiKeyCredential("not-required-by-llm-tck"),
93+
new OpenAIClientOptions { Endpoint = LlmTckToolReplay.CreateRouteUri(host.Endpoint) });
94+
using var client = sdk.GetChatClient(FileContextAgentLlmTckTests.Model).AsIChatClient().AsBuilder()
95+
.UseAIContextProviders(provider)
96+
.UseFunctionInvocation(configure: options => options.AllowConcurrentInvocation = concurrent).Build();
97+
var agent = new ChatClientAgent(client, new ChatClientAgentOptions { UseProvidedChatClientAsIs = true });
98+
99+
var session = await agent.CreateSessionAsync().ConfigureAwait(false);
100+
var response = await agent.RunAsync(prompt, session).ConfigureAwait(false);
101+
response.Text.ShouldBe(finalResponse);
102+
var saved = await agent.SerializeSessionAsync(session).ConfigureAwait(false);
103+
var restored = await agent.DeserializeSessionAsync(saved).ConfigureAwait(false);
104+
var followUp = await agent.RunAsync("Continue the inspection.", restored).ConfigureAwait(false);
105+
followUp.Text.ShouldBe("Follow-up completed.");
106+
LlmTckToolReplay.RecordedRequests.Count.ShouldBe(3);
107+
var assertions = await host.GetAssertionsAsync().ConfigureAwait(false);
108+
assertions.Matched.ShouldBe(3);
109+
assertions.Unmatched.ShouldBe(0);
110+
return LlmTckToolReplay.RecordedRequests.Skip(1).ToArray();
111+
}
112+
}

0 commit comments

Comments
 (0)