2025-06-30 16:56:48 +00:00
using System.Text ;
using System.Text.Json ;
2026-09-09 16:43:37 +00:00
using System.Runtime.CompilerServices ;
using AIStudio.Settings ;
2026-08-23 09:09:11 +00:00
using AIStudio.Tools.Security ;
2025-06-30 16:56:48 +00:00
2025-05-02 21:09:50 +00:00
namespace AIStudio.Tools.Services ;
public sealed partial class RustService
{
2026-08-10 14:37:44 +00:00
/// <summary>
/// How long one file extraction may take.
/// </summary>
/// <remarks>
/// Reading a large file from a slow network share is legitimately slow, so this is well above
/// the default HTTP client timeout. It still bounds the operation, because an unbounded read
/// would keep the caller waiting forever.
/// </remarks>
private static readonly TimeSpan EXTRACTION_TIMEOUT = TimeSpan . FromMinutes ( 10 ) ;
2026-08-23 19:15:37 +00:00
/// <summary>
/// Reads the content of an arbitrary file through the Rust runtime.
/// </summary>
/// <param name="path">The path of the file to read.</param>
/// <param name="maxChunks">How many chunks of the content stream we read at most.</param>
/// <param name="extractImages">Whether we want the images of the file as well.</param>
/// <param name="token">
/// Cancels the extraction when the caller no longer needs the content. Reading a large document
/// takes a while, and without this, the runtime would keep streaming into a caller which is
/// already gone.
/// </param>
/// <returns>The result of reading the file.</returns>
public async Task < FileExtractionResult > ReadArbitraryFileData ( string path , int maxChunks , bool extractImages = false , CancellationToken token = default )
2025-05-02 21:09:50 +00:00
{
2026-08-23 09:09:11 +00:00
//
// The runtime filters prompt injections while it streams the file. Doing it there rather
// than here means the whole document never has to exist in memory at once, which is what
// makes documents of a few thousand pages affordable.
//
var guardService = Program . SERVICE_PROVIDER . GetRequiredService < PromptInjectionGuardService > ( ) ;
2025-06-30 19:51:02 +00:00
var streamId = Guid . NewGuid ( ) . ToString ( ) ;
2026-09-09 16:43:37 +00:00
var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}&include_token_count=false" ;
2025-06-30 16:56:48 +00:00
2026-08-23 19:15:37 +00:00
//
// Both reasons to stop end the same read, so we combine them: our own timeout bounds the
// operation, and the caller's token ends it as soon as nobody needs the content anymore.
//
2026-08-10 14:37:44 +00:00
using var timeoutTokenSource = new CancellationTokenSource ( EXTRACTION_TIMEOUT ) ;
2026-08-23 19:15:37 +00:00
using var cancellationTokenSource = CancellationTokenSource . CreateLinkedTokenSource ( timeoutTokenSource . Token , token ) ;
var cancellationToken = cancellationTokenSource . Token ;
2025-05-02 21:09:50 +00:00
2025-06-30 16:56:48 +00:00
var resultBuilder = new StringBuilder ( ) ;
2026-08-10 14:37:44 +00:00
var failedPages = new List < int > ( ) ;
2026-08-23 09:09:11 +00:00
var promptInjectionFindings = new List < PromptInjectionFinding > ( ) ;
var promptInjectionRedactedCount = 0 ;
2026-08-10 14:37:44 +00:00
var hasPartialFailure = false ;
var failureCode = FileExtractionErrorCode . NONE ;
string? failureMessage = null ;
string? detectedFormat = null ;
2025-06-30 16:56:48 +00:00
2025-07-01 16:34:14 +00:00
try
2025-06-30 16:56:48 +00:00
{
2026-08-10 14:37:44 +00:00
using var request = new HttpRequestMessage ( HttpMethod . Get , requestUri ) ;
using var response = await this . extractionHttp . SendAsync ( request , HttpCompletionOption . ResponseHeadersRead , cancellationToken ) ;
if ( ! response . IsSuccessStatusCode )
{
var responseBody = await response . Content . ReadAsStringAsync ( cancellationToken ) ;
this . logger ? . LogError (
"Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'" ,
response . StatusCode ,
response . ReasonPhrase ,
path ,
responseBody ) ;
return FileExtractionResult . Failed ( FileExtractionErrorCode . REQUEST_FAILED , $"The runtime answered with the status {(int)response.StatusCode} ({response.ReasonPhrase})." ) ;
}
await using var stream = await response . Content . ReadAsStreamAsync ( cancellationToken ) ;
2025-07-01 16:34:14 +00:00
using var reader = new StreamReader ( stream ) ;
var chunkCount = 0 ;
2026-08-10 14:37:44 +00:00
while ( chunkCount < maxChunks )
2025-06-30 16:56:48 +00:00
{
2026-08-10 14:37:44 +00:00
// We read line by line instead of checking EndOfStream: the latter blocks on a
// network stream and cannot be cancelled, which would defeat the timeout above.
var line = await reader . ReadLineAsync ( cancellationToken ) ;
if ( line is null )
break ;
2025-07-01 16:34:14 +00:00
if ( string . IsNullOrWhiteSpace ( line ) )
continue ;
if ( ! line . StartsWith ( "data:" , StringComparison . InvariantCulture ) )
continue ;
var jsonContent = line [ 5. . ] ;
try
2025-06-30 16:56:48 +00:00
{
2025-07-01 16:34:14 +00:00
var sseEvent = JsonSerializer . Deserialize < ContentStreamSseEvent > ( jsonContent ) ;
2026-08-10 14:37:44 +00:00
if ( sseEvent is null )
continue ;
var processedEvent = ContentStreamSseHandler . ProcessEvent ( sseEvent , extractImages ) ;
if ( processedEvent . Error is not null )
2025-07-01 16:34:14 +00:00
{
2026-08-10 14:37:44 +00:00
var error = processedEvent . Error ;
//
// A notice is not a failure: the file was read completely, we only learned
// something about it worth telling the user. It must not change the outcome.
//
if ( error . IsNotice )
{
this . logger ? . LogInformation (
"The runtime reported a notice while reading '{Path}': code={ErrorCode}, detectedFormat='{DetectedFormat}', message='{Message}'" ,
path ,
error . ParsedCode ,
error . DetectedFormat ,
error . Message ) ;
detectedFormat ? ? = error . DetectedFormat ;
chunkCount + + ;
continue ;
}
this . logger ? . LogError (
"The runtime reported a failure while reading '{Path}': code={ErrorCode}, page={PageNumber}, partial={IsPartialFailure}, detectedFormat='{DetectedFormat}', message='{Message}'" ,
path ,
error . ParsedCode ,
error . PageNumber ,
error . IsPartialFailure ,
error . DetectedFormat ,
error . Message ) ;
2025-07-01 16:34:14 +00:00
2026-08-10 14:37:44 +00:00
//
// A partial failure costs us one part of the file, e.g. a single PDF page,
// but keeps the rest usable. Any other failure means what we collected is
// not the document the user picked, so we must not pass it on as content.
//
if ( error . IsPartialFailure )
{
hasPartialFailure = true ;
if ( error . PageNumber is { } pageNumber )
failedPages . Add ( pageNumber ) ;
}
else if ( failureCode is FileExtractionErrorCode . NONE )
{
failureCode = error . ParsedCode ;
failureMessage = error . Message ;
detectedFormat = error . DetectedFormat ;
}
2025-07-01 16:34:14 +00:00
}
2026-08-23 09:09:11 +00:00
else if ( processedEvent . PromptInjection is { } promptInjection )
{
//
// Not a failure: the passages were removed and the document around them is
// intact. It only needs to reach the user, so they know their document was
// changed before the AI saw it.
//
promptInjectionRedactedCount + = promptInjection . RedactedCount ;
if ( promptInjection . Findings is { } findings )
promptInjectionFindings . AddRange ( findings ) ;
}
2026-08-10 14:37:44 +00:00
else if ( processedEvent . Content is not null )
resultBuilder . AppendLine ( processedEvent . Content ) ;
chunkCount + + ;
2025-07-01 16:34:14 +00:00
}
2026-08-10 14:37:44 +00:00
catch ( JsonException e )
2025-07-01 16:34:14 +00:00
{
2026-09-09 16:43:37 +00:00
// The runtime may report a failure as a bare JSON string instead of a chunk.
// That form still carries a readable reason, so we log it as such -- but it
// remains a failure and must reach the caller like any other:
if ( ! this . TryLogSseErrorMessage ( jsonContent , path ) )
this . logger ? . LogError ( e , "Failed to deserialize SSE event while reading '{Path}': {JsonContent}" , path , jsonContent ) ;
2026-08-10 14:37:44 +00:00
if ( failureCode is FileExtractionErrorCode . NONE )
{
failureCode = FileExtractionErrorCode . INVALID_RESPONSE ;
failureMessage = "The runtime sent a response the app was not able to read." ;
}
2025-06-30 16:56:48 +00:00
}
}
2025-07-01 16:34:14 +00:00
}
2026-08-23 19:15:37 +00:00
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
//
// The caller dropped out, e.g. because the user closed the dialog which asked for this
// file. That is not a failure, so we log it as information and leave it to the caller
// to stay silent about it.
//
this . logger ? . LogInformation ( "Reading the file '{Path}' was cancelled by the caller." , path ) ;
return FileExtractionResult . Failed ( FileExtractionErrorCode . CANCELLED , "The caller cancelled reading the file." ) ;
}
2026-08-10 14:37:44 +00:00
catch ( OperationCanceledException ) when ( timeoutTokenSource . IsCancellationRequested )
{
this . logger ? . LogError ( "Reading the file '{Path}' timed out after {Timeout}." , path , EXTRACTION_TIMEOUT ) ;
return FileExtractionResult . Failed ( FileExtractionErrorCode . TIMEOUT , $"Reading the file timed out after {EXTRACTION_TIMEOUT.TotalMinutes:0} minutes." ) ;
}
catch ( Exception e )
2025-07-01 16:34:14 +00:00
{
this . logger ? . LogError ( e , "Error reading file data from stream: {Path}" , path ) ;
2026-08-10 14:37:44 +00:00
return FileExtractionResult . Failed ( FileExtractionErrorCode . INTERNAL , e . Message ) ;
2025-07-01 16:34:14 +00:00
}
finally
{
2026-09-09 16:43:37 +00:00
// Reading the whole file at once needs no token counts, so only the content is used here:
if ( ContentStreamSseHandler . Clear ( streamId ) is { } finalContentChunk & & ! string . IsNullOrWhiteSpace ( finalContentChunk . Content ) )
resultBuilder . AppendLine ( finalContentChunk . Content ) ;
2025-06-30 16:56:48 +00:00
}
2026-08-10 14:37:44 +00:00
if ( failureCode is not FileExtractionErrorCode . NONE )
return FileExtractionResult . Failed ( failureCode , failureMessage , detectedFormat ) ;
var content = resultBuilder . ToString ( ) ;
//
// Nothing failed, yet nothing came out either. We report this as a failure as well:
// handing an empty document to the AI looks like a file without content, and the user
// would never learn that reading the file did not work.
//
if ( string . IsNullOrWhiteSpace ( content ) )
{
this . logger ? . LogWarning ( "Reading the file '{Path}' produced no content at all." , path ) ;
return FileExtractionResult . Failed ( FileExtractionErrorCode . NO_CONTENT , "Reading the file produced no content." ) ;
}
2026-08-23 09:09:11 +00:00
var result = hasPartialFailure
2026-08-10 14:37:44 +00:00
? FileExtractionResult . Partial ( content , failedPages , detectedFormat )
: FileExtractionResult . Success ( content , detectedFormat ) ;
2026-08-23 09:09:11 +00:00
if ( promptInjectionRedactedCount is 0 )
return result ;
//
// Reported from here rather than from the callers: every way of reading a file passes
// through this method, so this is the one place where no caller can forget it.
//
await guardService . ReportAsync ( new ( PromptInjectionSource . FileContent ( path ) , promptInjectionFindings , promptInjectionRedactedCount ) ) ;
//
// Filtering does not change the outcome: the passages were removed and the document
// around them is intact. The findings travel along so a caller can show them next to
// the document they belong to.
//
return result with
{
PromptInjectionFindings = promptInjectionFindings ,
PromptInjectionRedactedCount = promptInjectionRedactedCount ,
} ;
2025-05-02 21:09:50 +00:00
}
2026-09-09 16:43:37 +00:00
public async IAsyncEnumerable < string > StreamArbitraryFileData ( string path , bool extractImages = false , [ EnumeratorCancellation ] CancellationToken token = default )
{
await foreach ( var segment in this . StreamArbitraryFileDataCore ( path , extractImages , false , string . Empty , token ) )
yield return segment . Content ;
}
public async IAsyncEnumerable < ArbitraryFileDataSegment > StreamArbitraryFileDataWithTokenCounts (
string path ,
EmbeddingProvider embeddingProvider ,
[EnumeratorCancellation] CancellationToken token = default )
{
await foreach ( var segment in this . StreamArbitraryFileDataCore ( path , false , true , embeddingProvider . TokenizerPath , token ) )
{
if ( segment . TokenCount is { } tokenCount )
{
yield return new ( segment . Content , tokenCount ) ;
continue ;
}
//
// A segment the runtime did not count, e.g. a page which carries an embedded image on
// top of its text. The runtime leaves such a count out on purpose instead of failing
// the extraction, because we can count the segment ourselves. Without this, a document
// would be dropped over a number we are able to produce.
//
var countedSegment = await this . GetTokenCount ( embeddingProvider , segment . Content , token ) ;
if ( countedSegment is { Success : true } counted )
{
yield return new ( segment . Content , counted . TokenCount ) ;
continue ;
}
//
// Carries a code so callers can classify it: the file itself is fine, the answer of
// the runtime was not, which makes this worth another attempt.
//
throw new FileExtractionException ( FileExtractionErrorCode . INVALID_RESPONSE , $"Rust did not return a token count for an extracted segment from '{path}' using provider '{embeddingProvider.Name}', and counting it afterwards failed as well: {countedSegment?.Message}" ) ;
}
}
private async IAsyncEnumerable < ( string Content , int? TokenCount ) > StreamArbitraryFileDataCore (
string path ,
bool extractImages ,
bool includeTokenCount ,
string tokenizerPath ,
[EnumeratorCancellation] CancellationToken token )
{
var streamId = Guid . NewGuid ( ) . ToString ( ) ;
var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}&include_token_count={includeTokenCount}&tokenizer_path={Uri.EscapeDataString(tokenizerPath)}" ;
using var request = new HttpRequestMessage ( HttpMethod . Get , requestUri ) ;
using var response = await this . http . SendAsync ( request , HttpCompletionOption . ResponseHeadersRead , token ) ;
if ( ! response . IsSuccessStatusCode )
{
var responseBody = await response . Content . ReadAsStringAsync ( token ) ;
this . logger ? . LogError (
"Failed to stream arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'" ,
response . StatusCode ,
response . ReasonPhrase ,
path ,
responseBody ) ;
if ( includeTokenCount )
throw new InvalidOperationException ( $"Rust could not extract and count '{path}'. HTTP {(int)response.StatusCode} ({response.ReasonPhrase}): {responseBody}" ) ;
yield break ;
}
var promptInjectionFindings = new List < PromptInjectionFinding > ( ) ;
var promptInjectionRedactedCount = 0 ;
ContentStreamPendingContent ? finalContentChunk ;
try
{
await using var stream = await response . Content . ReadAsStreamAsync ( token ) ;
using var reader = new StreamReader ( stream ) ;
while ( ! reader . EndOfStream & & ! token . IsCancellationRequested )
{
var line = await reader . ReadLineAsync ( token ) ;
if ( string . IsNullOrWhiteSpace ( line ) )
continue ;
if ( ! line . StartsWith ( "data:" , StringComparison . InvariantCulture ) )
continue ;
var jsonContent = line [ 5. . ] ;
ContentStreamSseEvent ? sseEvent = null ;
try
{
sseEvent = JsonSerializer . Deserialize < ContentStreamSseEvent > ( jsonContent ) ;
}
catch ( JsonException )
{
if ( this . TryLogSseErrorMessage ( jsonContent , path ) )
{
if ( includeTokenCount )
throw new InvalidOperationException ( $"Rust could not extract and count a segment from '{path}'. See the runtime log for details." ) ;
continue ;
}
this . logger ? . LogError ( "Failed to deserialize SSE event: {JsonContent}" , jsonContent ) ;
}
if ( sseEvent is null )
continue ;
var processedEvent = ContentStreamSseHandler . ProcessEvent ( sseEvent , extractImages ) ;
if ( processedEvent . Error is { } error )
{
// A notice says something about the file without failing the read, so the
// remaining content still belongs into the index:
if ( error . IsNotice )
{
this . logger ? . LogInformation (
"The runtime reported a notice while reading '{Path}' for embedding: code={ErrorCode}, detectedFormat='{DetectedFormat}', message='{Message}'" ,
path ,
error . ParsedCode ,
error . DetectedFormat ,
error . Message ) ;
continue ;
}
//
// Everything else stops the read. Embedding a document which was only read in
// part would put a silently incomplete text into the index, and nothing after
// this point would reveal the gap:
//
this . logger ? . LogError (
"The runtime reported a failure while reading '{Path}' for embedding: code={ErrorCode}, page={PageNumber}, detectedFormat='{DetectedFormat}', message='{Message}'" ,
path ,
error . ParsedCode ,
error . PageNumber ,
error . DetectedFormat ,
error . Message ) ;
throw new FileExtractionException ( error . ParsedCode , $"Rust could not extract '{path}': {error.Message}" , error . PageNumber , error . DetectedFormat ) ;
}
if ( processedEvent . PromptInjection is { } promptInjection )
{
//
// Not a failure: the passages were removed and the document around them is
// intact, so what remains still belongs into the index. It only has to reach
// the user, because from here on the indexed document is no longer the one
// sitting on their disk.
//
promptInjectionRedactedCount + = promptInjection . RedactedCount ;
if ( promptInjection . Findings is { } findings )
promptInjectionFindings . AddRange ( findings ) ;
continue ;
}
//
// The count comes from the processed event, not from the event which was just read:
// a reader may hold content back across several events, and the count of the content
// it releases is the count of that content, not of the event that released it.
//
if ( ! string . IsNullOrWhiteSpace ( processedEvent . Content ) )
yield return ( processedEvent . Content , processedEvent . TokenCount ) ;
}
}
finally
{
finalContentChunk = ContentStreamSseHandler . Clear ( streamId ) ;
}
if ( finalContentChunk is { } pendingContent & & ! string . IsNullOrWhiteSpace ( pendingContent . Content ) )
yield return ( pendingContent . Content , pendingContent . TokenCount ) ;
if ( promptInjectionRedactedCount is 0 )
yield break ;
//
// Reported from here for the same reason as in ReadArbitraryFileData above: these two
// methods together are every way of reading a file, so they are the only two places
// where no caller can forget the report. Here it was missing, which is why a whole
// indexing run could filter documents without ever saying so.
//
var guardService = Program . SERVICE_PROVIDER . GetRequiredService < PromptInjectionGuardService > ( ) ;
await guardService . ReportAsync ( new ( PromptInjectionSource . FileContent ( path ) , promptInjectionFindings , promptInjectionRedactedCount ) ) ;
}
private bool TryLogSseErrorMessage ( string jsonContent , string path )
{
try
{
var errorMessage = JsonSerializer . Deserialize < string > ( jsonContent ) ;
if ( string . IsNullOrWhiteSpace ( errorMessage ) )
return false ;
this . logger ? . LogError ( "Rust retrieval stream error for '{Path}': {ErrorMessage}" , path , errorMessage ) ;
return true ;
}
catch ( JsonException )
{
return false ;
}
}
}