LLMClientInterface (in Audit\Domain\Port\) is the sole seam between Application and LLM I/O.
php artisan boost:add-skill vinceamstoutz/symfony-security-auditor
Save this content to: .cursor/rules/llm-seam.md
# LLM Seam Rules
`LLMClientInterface` (in `Audit\Domain\Port\`) is the **sole seam** between Application and LLM I/O.
- `AttackerAgent` and `ReviewerAgent` must **never** import any `symfony/ai` type. Only `LLMClientInterface` and `LLMResponse` (both in `Audit\Domain\Port\`).
- `Audit\Infrastructure\LLM` is the only namespace that may import `symfony/ai`: `SymfonyAiLLMClient` implements the Domain ports and builds the platform-facing collaborators (`RetryingPlatformInvoker`, `SequentialToolLoop`, `BatchWindowResolver`, `ToolConversationWavefront`, `InFlightRequestCanceller`, `DegradedAnswerBooker`, `ToolIterationBooker`, `ConversionFailureExplainer`, `DispatchedRequest`, `PlatformResultExtractor`, `FinishReasonReader`, `PlatformOptionsFactory`, `PlatformToolsMapper`) that share the imports. Nothing outside that namespace touches `symfony/ai`.
- Swapping providers (Anthropic → OpenAI → Ollama) must require **zero code changes** — only `config/packages/ai.yaml`.
- `LLMResponse::parseJson()` strips markdown fences before decoding — always use it; never call `json_decode` directly on LLM output.
- **Transient/parsing errors** (JSON decode failure, generic `Throwable`) in agents must be caught and logged; return empty array / `reviewerValidated = false` rather than propagating — a single bad chunk must not abort the audit.
- **Non-transient provider failures** (`LLMProviderException` from `Audit\Domain\Exception\`) must be **rethrown** by agents. These represent misconfigured platforms, auth errors, or retired model names that will repeat on every call — swallowing them produces a false-negative SAFE result. `NonTransientLLMFailureException` (Infrastructure) extends `LLMProviderException` (Domain) so agents can catch the Domain type without importing Infrastructure.
- **A prompt the model cannot fit** — `LLMRequestTooLargeException` (Domain, extends `LLMProviderException`, raised by `RetryingPlatformInvoker` from `TransientFailureClassifier::isRequestTooLarge()` and never retried) — is the one provider failure an agent recovers from per chunk: it does not repeat on every call, so the chunk analyzers hand the chunk to `OversizedChunkRecovery`, which splits it in two, instead of rethrowing. Only a single file the model cannot fit is recorded as errored — and when that file is under a tenth of the refused prompt, the fixed part of the prompt is what leaves no room, so `LLMFixedPromptTooLargeException` stops the run. The batch port cannot throw for one request, so `ToolConversationWavefront` and `BatchWindowResolver` answer a refused request with a `request_too_large` response (`LLMResponse::isRequestTooLarge()`, degraded) instead of retrying or restarting it, and the concurrent analyzer splits on that answer. The reviewer recovers too: a single review records the finding as errored and moves on, and `BatchReviewAnalyzer` hands a refused batch to `OversizedReviewBatchRecovery`, which splits it.
- **An answer with nothing usable in it is a degraded response, never a failure.** A provider that reports the output-limit cut-off or the content filter as an exception (`symfony/ai`'s `MaxOutputTokensException`, `ContentFilterException`, or a plain one naming the provider's reason such as `Unsupported finish reason "content_filter".`) gets the same `length` / `content-filter` response as one that reports it as a finish reason — `TransientFailureClassifier::degradedStopReason()` names it, on every call path, without a retry. When the bridge's exception lost the reason (Azure's HTTP 400 `content_filter` becomes a bare `BadRequestException`, a gateway's HTTP 413 with an HTML body a `Syntax error` and with a JSON body `Response does not contain choices.`), `ConversionFailureExplainer` reads it back from the raw answer of the conversion that failed and wraps the failure in `UnconvertedAnswerException`, which the classifier reads first and alone: its stop reason decides, so a request it shows refused as too large is never taken for an empty answer, whatever the bridge's message says; any other HTTP client error except 408, 425 and 429 (a gateway's `{"detail":"Not Found"}` also reads `Response does not contain choices.`) is wrapped as a refusal that books nothing, since nothing was served, yet is classified by the bridge's own message as before — an empty answer ends the chunk as `empty_content` and the run goes on, a failure the bridge names aborts, a transient wording is retried. A 408, 425, 429 or 5xx is wrapped in `TransientHttpStatusException` before the bridge's message is read (the Ollama bridge reads any error body as `Response does not contain message.`, a proxy's HTML page as a `Syntax error`): it is transient, a rate limit when it is a 429, and books nothing; once the retries are spent, `RetryingPlatformInvoker` reads its `statusFreeFailure` instead, so a status that persists ends as it did before it was retried. A `MalformedToolCallException` is transient, unless its raw answer shows the output limit cut the tool call off — then it is a `length` answer like the others. `DegradedAnswerBooker::bookFailedCall()` books every failed call the provider answered (a degraded answer or a malformed tool call) where it failed, at the usage its raw answer reports or else its estimated input tokens; a failed call the provider never answered only releases its rate-limit reservation. `RetryingPlatformInvoker` checks the budget before every retry, so attempts it booked never buy another one past the budget; its first attempt is the caller's to check.
- **A recording tool takes a call in or refuses it.** `RecordingToolInterface::RECORDED` is the answer to a call the tool recorded; any other answer (an `Error: …` text the model reads and may retry from) is a refusal, which is not a recording. `FinalRound::isConcludedBy()` concludes a last round only on a recorded call, so a last round of refused calls ends as `max_tool_iterations`; `StructuredVulnerabilityCollectionSession` wraps the published recording tool in `RefusalTrackingRecordingTool`, which reports every refusal to `VulnerabilityCollector`, where a later recorded finding for the same file and title settles it. A chunk that ends with a refusal the model never repeated in full is recorded `errored` and left out of the cache by both chunk analyzers (`RefusedRecordingRecorder`), keeping the findings that were recorded, as a dropped finding is.
- `VulnerabilityFactory::fromArray()` returns `null` on invalid data — `fromList()` silently drops nulls. Do not throw from the factory.
- **Structured collection seam.** When `audit.structured_collection: true` (default), findings flow through `RecordVulnerabilityTool` (Infrastructure, Domain `ToolInterface`) into `VulnerabilityCollector` (Application). The tool's JSON-Schema input is the contract — the provider validates each call before invocation, so the agent never sees malformed payloads. A provider that does not enforce the schema gets the same check from the tool: `RequiredArguments` reads the tool's own `required` list, and a call missing a key (or giving a non-string for a string key) is answered with an `Error: …` text and never reaches the collector. To extend the contract (extra fields, tighter enums), swap `RecordVulnerabilityToolFactoryInterface` (Application) at the composition root; do **not** introduce post-hoc validation in the agent.
LLMClientInterface (in Audit\Domain\Port\) is the sole seam between Application and LLM I/O.
AttackerAgent and ReviewerAgent must never import any symfony/ai type. Only LLMClientInterface and LLMResponse (both in Audit\Domain\Port\).Audit\Infrastructure\LLM is the only namespace that may import symfony/ai: SymfonyAiLLMClient implements the Domain ports and builds the platform-facing collaborators (RetryingPlatformInvoker, SequentialToolLoop, BatchWindowResolver, ToolConversationWavefront, InFlightRequestCanceller, DegradedAnswerBooker, ToolIterationBooker, ConversionFailureExplainer, DispatchedRequest, PlatformResultExtractor, FinishReasonReader, PlatformOptionsFactory, PlatformToolsMapper) that share the imports. Nothing outside that namespace touches symfony/ai.config/packages/ai.yaml.LLMResponse::parseJson() strips markdown fences before decoding — always use it; never call json_decode directly on LLM output.Throwable) in agents must be caught and logged; return empty array / reviewerValidated = false rather than propagating — a single bad chunk must not abort the audit.LLMProviderException from Audit\Domain\Exception\) must be rethrown by agents. These represent misconfigured platforms, auth errors, or retired model names that will repeat on every call — swallowing them produces a false-negative SAFE result. NonTransientLLMFailureException (Infrastructure) extends LLMProviderException (Domain) so agents can catch the Domain type without importing Infrastructure.LLMRequestTooLargeException (Domain, extends LLMProviderException, raised by RetryingPlatformInvoker from TransientFailureClassifier::isRequestTooLarge() and never retried) — is the one provider failure an agent recovers from per chunk: it does not repeat on every call, so the chunk analyzers hand the chunk to OversizedChunkRecovery, which splits it in two, instead of rethrowing. Only a single file the model cannot fit is recorded as errored — and when that file is under a tenth of the refused prompt, the fixed part of the prompt is what leaves no room, so LLMFixedPromptTooLargeException stops the run. The batch port cannot throw for one request, so ToolConversationWavefront and BatchWindowResolver answer a refused request with a request_too_large response (LLMResponse::isRequestTooLarge(), degraded) instead of retrying or restarting it, and the concurrent analyzer splits on that answer. The reviewer recovers too: a single review records the finding as errored and moves on, and BatchReviewAnalyzer hands a refused batch to OversizedReviewBatchRecovery, which splits it.symfony/ai's MaxOutputTokensException, ContentFilterException, or a plain one naming the provider's reason such as Unsupported finish reason "content_filter".) gets the same length / content-filter response as one that reports it as a finish reason — TransientFailureClassifier::degradedStopReason() names it, on every call path, without a retry. When the bridge's exception lost the reason (Azure's HTTP 400 content_filter becomes a bare BadRequestException, a gateway's HTTP 413 with an HTML body a Syntax error and with a JSON body Response does not contain choices.), ConversionFailureExplainer reads it back from the raw answer of the conversion that failed and wraps the failure in UnconvertedAnswerException, which the classifier reads first and alone: its stop reason decides, so a request it shows refused as too large is never taken for an empty answer, whatever the bridge's message says; any other HTTP client error except 408, 425 and 429 (a gateway's {"detail":"Not Found"} also reads Response does not contain choices.) is wrapped as a refusal that books nothing, since nothing was served, yet is classified by the bridge's own message as before — an empty answer ends the chunk as empty_content and the run goes on, a failure the bridge names aborts, a transient wording is retried. A 408, 425, 429 or 5xx is wrapped in TransientHttpStatusException before the bridge's message is read (the Ollama bridge reads any error body as Response does not contain message., a proxy's HTML page as a Syntax error): it is transient, a rate limit when it is a 429, and books nothing; once the retries are spent, RetryingPlatformInvoker reads its statusFreeFailure instead, so a status that persists ends as it did before it was retried. A MalformedToolCallException is transient, unless its raw answer shows the output limit cut the tool call off — then it is a length answer like the others. DegradedAnswerBooker::bookFailedCall() books every failed call the provider answered (a degraded answer or a malformed tool call) where it failed, at the usage its raw answer reports or else its estimated input tokens; a failed call the provider never answered only releases its rate-limit reservation. RetryingPlatformInvoker checks the budget before every retry, so attempts it booked never buy another one past the budget; its first attempt is the caller's to check.RecordingToolInterface::RECORDED is the answer to a call the tool recorded; any other answer (an Error: … text the model reads and may retry from) is a refusal, which is not a recording. FinalRound::isConcludedBy() concludes a last round only on a recorded call, so a last round of refused calls ends as max_tool_iterations; StructuredVulnerabilityCollectionSession wraps the published recording tool in RefusalTrackingRecordingTool, which reports every refusal to VulnerabilityCollector, where a later recorded finding for the same file and title settles it. A chunk that ends with a refusal the model never repeated in full is recorded errored and left out of the cache by both chunk analyzers (RefusedRecordingRecorder), keeping the findings that were recorded, as a dropped finding is.VulnerabilityFactory::fromArray() returns null on invalid data — fromList() silently drops nulls. Do not throw from the factory.audit.structured_collection: true (default), findings flow through RecordVulnerabilityTool (Infrastructure, Domain ToolInterface) into VulnerabilityCollector (Application). The tool's JSON-Schema input is the contract — the provider validates each call before invocation, so the agent never sees malformed payloads. A provider that does not enforce the schema gets the same check from the tool: RequiredArguments reads the tool's own required list, and a call missing a key (or giving a non-string for a string key) is answered with an Error: … text and never reaches the collector. To extend the contract (extra fields, tighter enums), swap RecordVulnerabilityToolFactoryInterface (Application) at the composition root; do not introduce post-hoc validation in the agent.How can I help you explore Laravel packages today?