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, ...)isTransformAsync<string>.TransformAsync(input, intent, JsonNode outputSchema, ...), the runtime-shape overload, setsAIRequestOptions.OutputSchemaon a copy of the options (the caller's options are not changed) and callsTransformAsync<JsonNode>. It returns anAIResult<JsonNode>.GenerateAsync<T>(intent, ...)passes a null input.ExecuteIntentAsync<T>(MethodInfo, input, ...)buildsAIRequestOptionsfrom attributes (see below).- Everything in
AIExtensionsandSemanticDatabasecallsTransformAsync<T>.AmbientAIExtensionsreads the client fromIntentClientScope.Currentand calls the same methods.
Steps
1. Plan
PromptBuilder.Plan (Prompting/PromptBuilder.cs) produces a PromptPlan:
- Throws
ArgumentExceptionwhen the intent is empty or whitespace. - Serializes the input with
InputSerializer.Serialize(input, IntentClientOptions.MaxInputChars). Oversized input throwsArgumentException; it is never truncated. - Builds the schema. A
stringtarget has none. AJsonNodetarget (a runtime shape) getsJsonSchemaBuilder.BuildEnvelopeFromSchema(OutputSchema)whenAIRequestOptions.OutputSchemais set, and none otherwise. Every other target getsJsonSchemaBuilder.BuildEnvelope(targetType), which is null for types that cannot be expressed as a strict schema (prompting-and-schemas.md). - Copies
ExpectedOutputDescription,SystemInstructionsandExamplesfrom 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
AIProviderExceptionwithIsTransienttrue (429, 5xx, transport errors, or a success reply an HTTP provider could not read; see providers.md), or anAITimeoutException(the provider's HTTP timeout); - a fallback provider is configured and no provider override was given;
IntentClientOptions.FallbackOnTransientErrorsis 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
previousand 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.