Documentation
Table of Contents

Request pipeline

Every operation runs through IntentClient.TransformAsync<T>(object? input, string intent, AIRequestOptions? options, CancellationToken) in src/IntentSharp/IntentClient.cs. The other entry points delegate to it:

  • TransformAsync(input, intent, ...) is TransformAsync<string>.
  • TransformAsync(input, intent, JsonNode outputSchema, ...), the runtime-shape overload, sets AIRequestOptions.OutputSchema on a copy of the options (the caller's options are not changed) and calls TransformAsync<JsonNode>. It returns an AIResult<JsonNode>.
  • GenerateAsync<T>(intent, ...) passes a null input.
  • ExecuteIntentAsync<T>(MethodInfo, input, ...) builds AIRequestOptions from attributes (see below).
  • Everything in AIExtensions and SemanticDatabase calls TransformAsync<T>. AmbientAIExtensions reads the client from IntentClientScope.Current and calls the same methods.

Steps

1. Plan

PromptBuilder.Plan (Prompting/PromptBuilder.cs) produces a PromptPlan:

  • Throws ArgumentException when the intent is empty or whitespace.
  • Serializes the input with InputSerializer.Serialize(input, IntentClientOptions.MaxInputChars). Oversized input throws ArgumentException; it is never truncated.
  • Builds the schema. A string target has none. A JsonNode target (a runtime shape) gets JsonSchemaBuilder.BuildEnvelopeFromSchema(OutputSchema) when AIRequestOptions.OutputSchema is set, and none otherwise. Every other target gets JsonSchemaBuilder.BuildEnvelope(targetType), which is null for types that cannot be expressed as a strict schema (prompting-and-schemas.md).
  • Copies ExpectedOutputDescription, SystemInstructions and Examples from the request options.

These exceptions are thrown before the attempt loop, so they reach the caller instead of becoming issues.

2. Lint

IntentLint.Check(plan.Intent, typeof(T)) (Intent/IntentLint.cs) returns advisory strings. They land in AIResult.Warnings and never block a request.

3. Budgets

Per-request values fall back to client options: MaxAttempts (default 2), MaxModelCalls (default 6), Timeout (DefaultTimeout, default none). A timeout is enforced with a linked CancellationTokenSource.

The per-request limits are checked first, before the plan is built and before any model call, with the same rules as the client options: MaxAttempts, MaxModelCalls and MaxOutputTokens at least 1, Temperature between 0 and 2, Timeout greater than zero. A value out of range is a programming error, so it throws ArgumentOutOfRangeException ("MaxAttempts must be at least 1.") rather than ending as a failed result with no issue. An [AIGuardrail] value reaches the same check through ExecuteIntentAsync. IntentClientOptions.DefaultTimeout, when set, must also be greater than zero; Build() checks it with the other client options. The judge runs when a judge is configured and JudgeMode is Always, or OnRequest with AIRequestOptions.UseJudge set.

4. Build the request

Each attempt first checks the model-call ceiling and throws AIGuardrailException("MaxModelCalls", ...) when it is reached. Then PromptBuilder.Build(plan, previous, model, maxOutputTokens, temperature) creates an AIRequest. From attempt 2 on, previous carries the last attempt's raw output and issues. Request tags are copied onto AIRequest.Tags; providers never send them.

5. Cache

IntentClient.CallProviderAsync checks the cache only when all hold: a cache is configured, IntentClientOptions.UseCache is true, AIRequestOptions.BypassCache is false, and this is the first attempt. The key is RequestCacheKey.For(provider.Name, request) (Resilience/RequestCache.cs). A hit is not counted as a model call and adds no tokens to AIResult.Usage. A fresh response is stored unless it is a refusal or truncated.

A hit goes through verification and the judge like a fresh reply. If it does not pass, the attempt is discarded: no AttemptRecord, no issues, no usage, and the attempt number is not used up. The rest of the request stops reading the cache and the same attempt is re-run against the provider; the fresh reply overwrites the cache entry, so later identical requests do not receive the rejected reply.

6. Rate limit

IntentClient.InvokeAsync calls IRateLimiter.AcquireAsync(provider.Name, TimeSpan.FromSeconds(30), ct) when a limiter is configured. A wait longer than 30 seconds throws AIRateLimitException. Judge calls acquire a token the same way, from the primary provider's bucket (step 9).

7. Provider and fallback

The provider is AIRequestOptions.Provider matched by name (case-insensitive), or the primary. An unknown name throws ArgumentException. Names are unique: IntentClientBuilder refuses a primary and a fallback with the same name, compared ignoring case, with ArgumentException, so a name always selects one provider and one rate-limit bucket. InvokeAsync calls IAIProvider.CompleteAsync and adds the usage to the client totals.

Fallback happens in CallProviderAsync when every condition holds:

  • the call threw an AIProviderException with IsTransient true (429, 5xx, transport errors, or a success reply an HTTP provider could not read; see providers.md), or an AITimeoutException (the provider's HTTP timeout);
  • a fallback provider is configured and no provider override was given;
  • IntentClientOptions.FallbackOnTransientErrors is true (default);
  • the failing provider is not already the fallback.

Nothing else falls back. Authentication (401, 403), validation (400, 422) and other non-transient statuses such as 404 are not transient, so they end the request with a Provider issue. A local rate-limit refusal (AIRateLimitException) is transient, so it falls back.

8. Deterministic verification

DeterministicVerifier.Verify<T>(response, plan, options, attempt) (Verification/DeterministicVerifier.cs) checks refusal, truncation, emptiness, JSON, envelope, deserialization and caller validators. See verification.md.

9. Judge

Only when verification passed and the judge is enabled. The model-call ceiling is checked again first. When a rate limiter is configured, a token is acquired for the primary provider's name first. Then IResultJudge.JudgeAsync(plan, response.Content, IntentClientOptions.JudgeModel, ct) runs; a failing verdict adds IssueSource.Judge issues. The judge call counts toward the request's model-call ceiling and IntentClient.TotalModelCalls, and its usage is added to the result and client token totals.

10. Record the attempt and decide

An AttemptRecord is added for every attempt. On a pass, the method builds a successful AIResult<T>, records it in memory and returns. On a fail:

  • a refusal ends the loop (retrying the same input rarely helps);
  • otherwise the record becomes previous and the next attempt carries its findings.

11. Failures that end the loop

Caught in TransformAsync Becomes
AIGuardrailException IssueSource.Guardrail issue
OperationCanceledException while the time budget's own token has fired IssueSource.Guardrail issue: "The request exceeded its 0.05s time budget."
AIProviderException (any subtype) IssueSource.Provider issue
AITimeoutException (HTTP timeout) IssueSource.Provider issue

Cancellation by the caller's token propagates as an exception. So does an OperationCanceledException that neither the caller nor the budget caused, for example a custom provider's HttpClient.Timeout expiring: with no budget set, or a budget that has not run out, it is not this request running out of time. Other exceptions (for example from a custom provider) also propagate. ChatClientProvider in IntentSharp.Extensions.AI translates every failure of its chat client into one of the provider exceptions above, so those reach the table rather than the caller (extensions-ai.md). The failed AIResult<T> has Success = false, Value = default, and the last provider, model and raw output.

12. Record in memory

IntentClient.RecordAsync writes a MemoryEntry for successes and failures. It skips when there is no memory, RecordInMemory is false, AIRequestOptions.DoNotRecord is set, or the result came from the cache. The input preview is cut to MemoryEntry.MaxPreviewChars (2000). Memory errors are logged and ignored.

ExecuteIntentAsync

ExecuteIntentAsync<T> reads [AIIntent] from the method, else from its declaring type, and throws InvalidOperationException when neither has one. Description becomes SystemInstructions and ExpectedOutput becomes ExpectedOutputDescription. Every [AIGuardrail] on the declaring type, then on the method, is applied in that order with AIGuardrailAttribute.ApplyTo. A later value of the same type replaces an earlier one, so a method-level guardrail overrides a class-level one (validators add up instead). The method body never runs.

Telemetry

An ActivitySource named IntentSharp starts IntentSharp.Transform with the tags intentsharp.target_type and intentsharp.success. IntentClient.TotalUsage and TotalModelCalls accumulate across the client's lifetime. Each provider call and each failed attempt log at Debug; fallback logs at Warning.