Skip to content

Commit 32efed7

Browse files
committed
Return explicit found and not_found metadata tool results
1 parent 4424efc commit 32efed7

6 files changed

Lines changed: 39 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to ManagedCode.FileContext are documented here.
55
## Unreleased
66

77
- 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.
8+
- Return structured found/not_found metadata tool results with the logical path, preserving them through session restoration; keep the nullable direct API unchanged.
99
- Verify tool-result ordering, empty/error outputs, restored-session follow-ups, and concurrent multi-file operations through real filesystem and LlmTck tests.
1010
- 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.
1111

docs/Features/file-context.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ 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 direct `AgentFileStore` and `IFileContext.GetInfoAsync` contracts; range reads and the `file_context_info` tool throw `FileNotFoundException`.
51+
- Missing files return `null` through the direct `AgentFileStore` and `IFileContext.GetInfoAsync` contracts; range reads throw `FileNotFoundException`. The `file_context_info` tool returns a structured `not_found` result with the logical path.
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

5757
## Empty results and conversation history
5858

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.
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 returns `{ "status": "not_found", "path": "missing.txt" }` for an absent file. Existing files return `{ "status": "found", "path": "notes.txt", "info": { ... } }`, where `info` contains the file metadata. Both structured results survive session restoration; a bare null function result would become empty wire content in the tested Agent Framework pipeline. Storage failures and invalid paths still fail normally. 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.
6060

6161
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.
6262

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ManagedCode.FileContext;
4+
5+
internal sealed record FileContextInfoToolResult(
6+
string Status,
7+
string Path,
8+
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] FileContextInfo? Info);

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. Fails if the file does not exist.";
12+
public const string GetInfo = "Return status, path, and file metadata without reading content. Status is found with info when the file exists, or not_found when it does not.";
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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ public Task<FileContextRange> ReadRangeAsync(
1515
}
1616

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

2626
[Description(FileContextToolDescriptions.SearchMarkdownGraph)]

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public sealed class FileToolResultProtocolTests(Xunit.Abstractions.ITestOutputHe
1515
[InlineData(FileAccessProvider.ReadFileToolName, "{\"fileName\":\"missing.txt\"}", "not found")]
1616
[InlineData(FileAccessProvider.LsToolName, "{\"directory\":\"empty\"}", "[]")]
1717
[InlineData(FileAccessProvider.GrepToolName, "{\"regexPattern\":\"absent\",\"directory\":\"\"}", "[]")]
18-
[InlineData(FileContextToolNames.GetInfo, "{\"path\":\"missing.txt\"}", "Error: Function failed.")]
18+
[InlineData(FileContextToolNames.GetInfo, "{\"path\":\"missing.txt\"}", "not_found")]
1919
[InlineData(FileContextToolNames.ReadRange, "{\"path\":\"empty.txt\"}", "\"content\": \"\"")]
2020
[InlineData(FileContextToolNames.ReadRange, "{\"path\":\"first.txt\",\"startLine\":100}", "\"content\": \"\"")]
2121
[InlineData(FileContextToolNames.ReadRange, "{\"path\":\"missing.txt\"}", "Error: Function failed.")]
@@ -34,6 +34,29 @@ public async Task EmptyOrFailedTool_StillSendsMatchingResult(string toolName, st
3434
}
3535
}
3636

37+
[Theory]
38+
[InlineData("missing.txt", "not_found", false)]
39+
[InlineData("first.txt", "found", true)]
40+
public async Task MetadataLookup_PreservesStructuredResultAfterSessionRestore(string path, string status, bool exists)
41+
{
42+
var requests = await RunToolLoopAsync(LlmTckToolReplay.CreateResponse(
43+
"metadata", FileContextToolNames.GetInfo, JsonSerializer.Serialize(new { path })));
44+
45+
foreach (var request in requests)
46+
{
47+
var results = LlmTckToolAssertions.AssertClosedCalls(request, "metadata");
48+
using var content = JsonDocument.Parse(results[0].GetProperty("content").GetString()!);
49+
content.RootElement.GetProperty("status").GetString().ShouldBe(status);
50+
content.RootElement.GetProperty("path").GetString().ShouldBe(path);
51+
content.RootElement.TryGetProperty("info", out var info).ShouldBe(exists);
52+
if (exists)
53+
{
54+
info.GetProperty("path").GetString().ShouldBe(path);
55+
info.GetProperty("length").GetUInt64().ShouldBe((ulong)13);
56+
}
57+
}
58+
}
59+
3760
[Theory]
3861
[InlineData(false)]
3962
[InlineData(true)]

0 commit comments

Comments
 (0)