> ## Documentation Index
> Fetch the complete documentation index at: https://docs.instructorphp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# V2.7.0

v2.7 makes agent behaviour testable, makes the runtime measurable, and turns observability on
by default. It also restructures several provider and response boundaries and tightens what the
messages layer accepts, so read the breaking changes before upgrading.

Most of those breaks are loud — a missing class or method fails immediately on upgrade. Two are
not, and are worth a deliberate look: `Messages::filter()` no longer drops empty messages
alongside your predicate, which changes result sets silently, and `updatedAt` on Polyglot's
inference data objects stops advancing on every copy, which changes serialized output.

## Breaking Changes

Two upgrade guides carry the migration detail, including the edge cases:
`packages/instructor/docs/upgrade.md` for the structured-output changes and
`packages/polyglot/docs/upgrade.md` for the driver changes.

### StructuredOutputConfigBuilder Removed

`Cognesy\Instructor\Creation\StructuredOutputConfigBuilder` is gone. It was a mutable second
representation of the same settings, kept in sync with `StructuredOutputConfig` by hand. Build
configs directly instead — every `with*()` method has the same name on `StructuredOutputConfig`,
which returns a new instance rather than mutating, so existing chains carry over by dropping the
trailing `->create()`:

```php theme={null}
// before
$config = (new StructuredOutputConfigBuilder())
    ->withOutputMode(OutputMode::Json)
    ->withMaxRetries(2)
    ->create();

// after
$config = (new StructuredOutputConfig())
    ->withOutputMode(OutputMode::Json)
    ->withMaxRetries(2);
```

The builder's `withConfig($defaults)` seed has no replacement: start the chain from that config
instead. `StructuredOutputConfig` gained `withSchemaDescription()` and
`withStreamMaterializationInterval()`, so every former builder method has a namesake with two
exceptions: `withThrowOnTransformationFailure()` and `withDefaultToStdClass()` have no fluent
equivalent. Both were deprecated no-ops as of 2.5 — transformation failures already always fail
the attempt — and both values are still accepted as named arguments to the constructor and to
`with()`, so config round-trips are unaffected.

One semantic difference is worth checking. The builder's `create()` merged `modePromptClasses`
into the defaults whatever route set them. On `StructuredOutputConfig`,
`withModePromptClass()` merges a single mode into the existing map — the common case,
unchanged — but `withModePromptClasses()` and the constructor argument **replace** the map
wholesale. If you passed a partial map expecting the remaining modes to keep their defaults,
merge explicitly:

```php theme={null}
$config = $config->withModePromptClasses([...$config->modePromptClasses(), ...$overrides]);
```

In `cognesy/instructor-agents`, `StructuredOutputPolicy::withConfigBuilder()` is accordingly
renamed to `withConfig()` and now takes and returns a `StructuredOutputConfig`.

### Legacy Inline Prompt Settings Removed

The legacy `RequestMaterializer` is gone, along with the only code path that read the inline
`modePrompts`, `retryPrompt`, and `chatStructure` settings. Those settings are removed from
`StructuredOutputConfig`, together with the accessors `prompt()`, `modePrompts()`,
`retryPrompt()`, `chatStructure()` and the mutators `withRetryPrompt()`, `withModePrompt()`,
`withModePrompts()`, and `withChatStructure()`.

`StructuredOutputConfig::fromArray()` now ignores unknown keys instead of failing, so config
files, DSNs, and presets that still carry the removed keys keep loading — their values are
dropped rather than causing an error. The Laravel `instructor.extraction.retry_prompt` key and
the Symfony `retry_prompt` / `mode_prompts` / `chat_structure` nodes were removed from their
schemas.

Port these settings to prompt classes (`modePromptClasses`, `retryPromptClass`,
`deserializationErrorPromptClass`), or implement `CanMaterializeRequest` when prompt classes are
insufficient.

### CanEmitStreamingUpdates Renamed to CanDriveExecution

The execution-driver contract implemented by `SyncExecutionDriver` and `StreamingExecutionDriver`
is renamed from `Cognesy\Instructor\Contracts\CanEmitStreamingUpdates` to
`Cognesy\Instructor\Contracts\CanDriveExecution`. The old name described it as a streaming
contract, but it is a pull-based execution driver used by both the sync and streaming paths.
Method signatures are unchanged; update any type hints referencing the old name.

### Custom Embeddings Drivers Return Domain Responses

`CanHandleVectorization` no longer exposes Polyglot's HTTP transport boundary. Its `handle()`
method now returns `EmbeddingsResponse`, and `fromData()` is no longer part of the interface:

```diff theme={null}
- public function handle(EmbeddingsRequest $request): HttpResponse;
- public function fromData(array $data): ?EmbeddingsResponse;
+ public function handle(EmbeddingsRequest $request): EmbeddingsResponse;
```

PHP validates interface return types when implementations are loaded, so this change cannot
be delivered through a deprecation shim. Custom drivers must update their implementation in
the same deployment that upgrades to v2.7. Move HTTP response decoding and response adaptation
into `handle()`. Drivers extending `BaseEmbedDriver` inherit the new behavior unless they
override `handle()` themselves.

This makes the driver responsible for its complete provider boundary and keeps
`PendingEmbeddings` focused on retries, memoization, and lifecycle events.

### Bundled Inference Drivers Are Declarative

All 26 provider driver shells under `Cognesy\Polyglot\Inference\Drivers\` are gone:
`A21Driver`, `CerebrasDriver`, `DeepseekDriver`, `FireworksDriver`, `GlmDriver`, `GroqDriver`,
`InceptionDriver`, `MetaDriver`, `MinimaxiDriver`, `MistralDriver`, `OpenAIDriver`,
`OpenAICompatibleDriver`, `OpenRouterDriver`, `PerplexityDriver`, `QwenDriver`, `SambaNovaDriver`,
`XAiDriver`, `AnthropicDriver`, `AzureDriver`, `BedrockOpenAIDriver`, `CohereV2Driver`,
`GeminiDriver`, `GeminiOAIDriver`, `HuggingFaceDriver`, `OpenAIResponsesDriver`, and
`OpenResponsesDriver`. (The embeddings driver of the same short name,
`Embeddings\Drivers\OpenAI\OpenAIDriver`, is untouched.) Each was a class whose only content
was composing provider-specific collaborators. All 29 bundled registry names — including aliases
— are now rows of an `InferenceDriverSpec` table in `BundledInferenceDrivers`, and one
`SpecifiedInferenceDriver` serves them all. OpenAI-compatible providers use the shared defaults;
native protocols and provider-specific endpoints name their own body, request, response, usage, or
message adapters in the same declarative row.

Preset names and request/response behavior are unchanged. `Inference`, `PendingInference`,
`InferenceStream`, `InferenceResponse`, `Embeddings`, `PendingEmbeddings`, and `LLMConfig` keep
their public APIs, and every `Cognesy\Polyglot\Inference\Contracts\*` interface is unchanged.
`InferenceDriverRegistry::withDriver()` still accepts a class-string or a callable, so custom
registrations keep working.

What breaks is naming one of the deleted classes. Extending one — most often `OpenAIDriver` or
`OpenAICompatibleDriver` to change one method — now means extending `SpecifiedInferenceDriver`
and naming your subclass in the spec's `driverClass`, or supplying a spec with your own body
format, request adapter, response adapter, usage format, or message format. Per-model capability
logic that used to be a `capabilities(?string $model)` override becomes `DriverCapabilities` data
on the spec, either a fixed instance or a closure over the model name (`deepseek` is the one
bundled provider that needs the closure).

`Cognesy\Polyglot\Inference\Contracts\MessageMapper` moved to
`Cognesy\Polyglot\Inference\Drivers\MessageMapper` — it is a driver helper, not a contract.
Update the import; the class is otherwise unchanged. This is the one move with no alias.

One observable change on the data objects: `InferenceRequest::with()`, `InferenceAttempt::with()`,
and `InferenceResponse::with()` no longer recompute `updatedAt` on every immutable copy, carrying
the existing value over instead. `updatedAt` is public readonly and appears in `array()` output,
so serialized payloads change: it now reflects creation time and stays constant across the
object's lifetime, rather than tracking the last `with()` call.

Three unreferenced classes were deleted outright: `Inference\Enums\InferenceContentType`,
`Inference\Collections\InferenceResponseList`, and `Embeddings\Traits\HasFinders`. None had a
single usage anywhere in the repository.

Four classes that were never inference concepts moved to a neutral namespace —
`Inference\Core\SensitiveDataRedactor`, `Inference\Config\RetryBackoff`,
`Inference\Config\RetryJitter`, `Inference\Config\RetryPolicyInvariants` — as did
`Polyglot\Pricing\Cost`, joining them under `Polyglot\Support\`. All five old names keep
working: the package registers a lazy `class_alias()` autoloader, so an old FQCN resolves to the
same class, `instanceof` holds in both directions, and `RetryJitter` enum case identity is
preserved. Migrate at your convenience; the aliases go at the next major.

### Response Generation and Validation Contracts Carry Telemetry Context

`CanGenerateResponse::makeResponse()` is replaced by two methods that name the two genuinely
different routes to a response value. The sync path always has an inference response to
extract from; the streaming path may already hold a value the aggregator built from deltas.

```diff theme={null}
- public function makeResponse(
-     InferenceResponse $response,
-     ResponseModel $responseModel,
-     OutputMode $mode,
-     mixed $materializationInput = null,
- ) : Result;
+ public function fromInferenceResponse(
+     InferenceResponse $response,
+     ResponseModel $responseModel,
+     OutputMode $mode,
+     ?PhaseTelemetryContext $extractionTelemetry = null,
+     ?PhaseTelemetryContext $validationTelemetry = null,
+ ) : Result;
+ public function fromMaterializedInput(
+     mixed $input,
+     ResponseModel $responseModel,
+     ?PhaseTelemetryContext $validationTelemetry = null,
+ ) : Result;
```

`CanValidateResponse::validate()` gains an optional trailing `?PhaseTelemetryContext`:

```diff theme={null}
- public function validate(object $response, ResponseModel $responseModel) : Result;
+ public function validate(
+     object $response,
+     ResponseModel $responseModel,
+     ?PhaseTelemetryContext $telemetry = null,
+ ) : Result;
```

Callers of `validate()` need no change, and neither do existing implementors: PHP permits an
interface to add trailing optional parameters without forcing implementations to redeclare them.
Add the parameter only if you want your own validator or response generator stamped into
telemetry. The one hard break here is `makeResponse()` disappearing, which affects anyone who
implemented `CanGenerateResponse` themselves — both implementations in this repository were test
doubles.

`ResponseGenerator`, the bundled implementation, also narrowed its constructor from
`(CanDeserializeResponse, CanValidateResponse, CanTransformResponse, CanExtractResponse)` to
`(ResponseMaterializer, CanExtractResponse)`, and its `materializer()` getter is gone. It is
internal wiring rather than typical end-user surface, but it is a public class, so constructing
it directly needs updating.

Both contexts are optional at the call site. They are supplied by callers that hold a
structured-output execution, so extraction and validation events can be stamped as children of
its root; both stages work unchanged without them.

### Sections Reject Duplicate Names

`Sections` always assumed names were unique — every accessor (`has()`, `get()`, `set()`,
`merge()`, `MessageStore::section()`) matches on name and stops at the first hit, so a second
section sharing a name was unreachable by every accessor yet still contributed its messages to
`toMessages()`. `add()` enforced uniqueness; the constructor did not.

The constructor now throws `InvalidArgumentException` on a duplicate name. Code that
constructed `Sections` with a repeated name was already producing a collection whose invariants
did not hold, and now fails loudly instead.

`Sections::fromArray()` deliberately does not throw. Deserialization must not fail on data an
earlier version was able to write, so duplicates are merged there: the later section's messages
are appended to the first one under that name.

### Messages::withMessage() Removed

`Messages::withMessage()` read as a wither that added a message, but it discarded the collection
and returned one holding only the argument. Every other `with*()` on the class keeps the
receiver's contents. It is removed rather than fixed, because either reading of the name is a
reasonable one to have depended on:

```php theme={null}
// if you wanted the message appended
$messages = $messages->appendMessage($message);

// if you wanted a collection of exactly that message
$messages = $messages->withMessages([$message]);
```

`CanStoreMessages::save()` is unchanged in signature and behaviour, but its contract is now
stated explicitly: it **replaces** the session with exactly the store passed in, so saving a
store built from a subset of a session prunes the session to that subset. `load()` returns the
whole session, so load → mutate → save is lossless. Use `append()` with `navigateTo()` for
additive writes.

### Messages::filter() Is Now a Pure Predicate

`filter(callable $callback)` used to drop empty messages *in addition to* applying your
predicate. It now applies only the predicate, so a filter that accepts everything returns
everything:

```php theme={null}
// two messages, one of them empty
$messages->filter(fn() => true)->count();   // was 1, is now 2
```

This is a silent change in result sets rather than an error, so it deserves a look wherever you
filter messages. Chain `->filter($cb)->withoutEmptyMessages()` to restore the old behaviour. The
**no-argument** form is untouched: `filter()` remains a deprecated alias for
`withoutEmptyMessages()`, so that call shape means exactly what it always did.

### Invalid Message Roles Throw Instead of Being Coerced

Role handling had two contradictory silent policies. `new Message('human')` stored the string
raw and threw only later, at the first `role()` call — far from the cause, and after
`toArray()` had already round-tripped the bad value through storage. Meanwhile
`MessageInput::normalizeRole()` coerced an unrecognized role to `user`, so the same `'human'`
became `'assistant'`-adjacent silent data corruption on the array-ingestion path: a wrong role,
no signal.

Both paths now validate at construction. `new Message($role)`, `Message::withRole()`,
`Message::fromArray()`, and `MessageInput::fromArray()` throw
`InvalidArgumentException("Invalid message role: {$role}")` for anything outside `system`,
`developer`, `user`, `assistant`, and `tool`. An empty string or null still means the default
role. If you ingest provider-specific or user-supplied role strings, map them to one of the five
cases before constructing.

### Stricter Failure at the Messages Boundary

Three smaller changes in how invalid input fails:

* `fork()` on an unknown message id now throws `RuntimeException("Message not found: ...")` in
  both storage backends. It previously created a session with no messages and a `leafId`
  pointing at nothing, so the next `append()` stamped a dangling `parentId` onto a real message.
  `JsonlStorage` performs the check before creating the file, so a rejected fork leaves no
  orphan behind.
* `ToolCalls::fromArray()` throws `InvalidArgumentException` naming the offending index for a
  bare string at a list position, where it previously surfaced a raw `TypeError` from inside
  `ToolCall::__construct()`. Catch the new type if you were catching `TypeError` here.
* `ContentPart::toString()` returns `''` instead of raising a `TypeError` when the `text` field
  holds a non-string. This is the one change in the other direction, and deliberately so: it
  runs on provider serialization paths, where an empty part beats a fatal.

## Agent Evals

`packages/agents` gains an eval harness for grading agent behaviour, not just asserting on
final output.

Deterministic assertions cover the trajectory — which tools ran, in what order, with what
outcomes. Semantic judging covers the rest through `AgentLoopJudge`, which grades a run using a
terminal submission protocol: the judge returns its verdict by calling `SubmitJudgmentTool`
rather than emitting prose to be parsed, so a malformed or missing submission raises
`JudgeProtocolException` instead of silently mis-scoring the run. Judge tools are read-only.

`EvalRepetition` runs a case N times and resolves a verdict from the observed pass rate, which
makes flaky agent behaviour measurable rather than a coin toss.

`EvalTracePolicy` governs what a trace may contain. By default (`EvalTracePolicy::safe()`) tool
arguments and results are reduced to shape-only previews that never include payload values;
`full()` is an explicit opt-in. `ArtifactEvalReporter` writes run artifacts to disk with
provenance and cost, and provenance deliberately excludes credentials, keyed base URLs, and
full LLM configuration dumps.

Five documentation pages cover the harness end to end — evals, assertions, judges, traces and
artifacts, and running evals from the CLI or from PHPUnit and Pest — alongside thirteen runnable
`D06_AgentEvals` examples.

## A Typed Metric Catalog

Before v2.7 the runtime emitted exactly two metrics, both histograms, while `Counter`, `Gauge`,
and `Timer` sat unused in `packages/metrics`. The two runtime projectors now emit a full
catalog.

`PolyglotTelemetryProjector` adds `inference.client.operation.count` and `.duration`,
`inference.client.attempt.count` and `.duration`, and `inference.embeddings.operation.count`.
`AgentsTelemetryProjector` adds `agent.step.count` and `.duration`, `agent.execution.count` and
`.steps`, `agent.tool_call.count` and `.duration`, `agent.subagent.count`, plus two gauges:
`agent.context.message_count` and `agent.subagent.depth`. Blocked tool calls share
`agent.tool_call.count` so the denominator stays whole — success plus error plus blocked is
every call the agent attempted.

Metric names keep their existing `inference.*` and `agent.*` prefixes. Tags are aggregation
dimensions and are deliberately low-cardinality: per-run identifiers belong on spans, because
one tag value per run means one time series per run in the metrics backend.
`inference.client.token.usage.*` is the single documented exception, since Langfuse correlates
it back to its span by id.

The full catalog, projector by projector, with the type policy and the tag rules, is in the
telemetry package's runtime wiring documentation.

## Structured Output and Validation Are Visible in Traces

Two spans that traces were supposed to contain were never actually reaching exporters, for two
different reasons. Both now appear.

`structured_output.validate` did not exist as a span at all. The projector mapped
`ResponseValidationFailed` straight to an error log and had no handling for the attempt or
success events, so a passing validation left no trace whatsoever and a failing one produced a
log line indistinguishable from any other logged error — no span, no duration, no error count.
Validation now opens a span on the attempt and closes it OK or Error, carrying
`structured_output.validation.error_count` on failure. The error log is kept only for
un-enveloped legacy emitters, so nothing is double-reported.

`structured_output.extract` was worse: the projector code to emit it existed, but
`ExtractingBuffer` never wrote the `executionId` and `phaseId` keys that the projector's
reconstruction path required, so its early return fired on every real extraction and no span
was ever produced — silently, with no error. Extraction telemetry moves to envelope-first
emission and the span now nests under `structured_output.execute`. `ExtractionFailed` also
gained a flat `error` key, which the projector's `error.message` attribute had been reading
without it ever being populated.

`PhaseTelemetryContext` carries the phase correlation both paths previously rebuilt ad hoc.
`StructuredOutputTelemetry::extractionContext()`, `::validationContext()`, and `::phaseId()` are
the public factories for it, for anyone driving a custom attempt processor.

W3C trace-context propagation over HTTP is now wired end to end and testable, though not
automatic. `TraceContextMiddleware` existed but was registered nowhere; `HttpClientBuilder` now
always appends it, innermost in the stack. It stays inert unless a caller stamps a request via
the new `HttpRequestTelemetry::withTraceContext()` seam, so outgoing requests are not traced by
default — application code has to opt in. `packages/http-client/CHEATSHEET.md` has the snippet.

For `agent-ctrl`, session correlation is bound at execution start. `AgentExecutionStarted` and
`AgentExecutionCompleted` expose `sessionId(): ?AgentSessionId`, populated only when the caller
resumed a specific session — the earliest point the runtime honestly knows one. A fresh run has
no session until the agent reports it, and `continueSession()` selects "the most recent"
without naming it, so both stay null rather than carrying an invented placeholder. Execution
roots stay keyed on execution id, so two runs sharing a session remain distinct traces that
merely correlate.

## JSONL Event Logging On By Default

An `agent-ctrl` bridge constructed without an explicit event dispatcher now receives
`EventLog::root('agent-ctrl.<bridge>')` instead of a bare `EventDispatcher`, so bridge runs
produce a JSONL event log out of the box. Passing your own dispatcher opts out entirely.

Because a file sink is now on by default, the default capture policy is bounded and redacted
rather than verbatim:

* `stringClipLength` defaults to `2048` instead of `0` (unbounded), so a single large response
  body cannot dominate a log file.
* `redactKeys` defaults to a built-in list. Values under matching keys are replaced with
  `[redacted]` before being written, and matching ignores case and separators, so
  `authorization` also covers `Proxy-Authorization`. Setting it to `[]` disables redaction,
  which is not recommended: HTTP events carry raw request headers.

`FileJsonLogWriter` degrades rather than failing: a record whose context cannot be JSON-encoded
is written with an `encoding_error` marker instead of being dropped silently, and logging
failures never escape into the caller.

## MessageStore Survives Partially Corrupt Sessions

A JSONL record written by an older version, hand-edited, or truncated mid-write used to throw
on load and make an entire session unopenable. Unhydratable records are now quarantined and
reported through `quarantined()`, the walk past them continues so ancestors stay readable, and a
header naming an unparseable leaf falls back to "last message wins" rather than failing the
open.

`Sections::select()` already existed; what changed is that it now collapses a repeated name
instead of returning that section twice. It previously built its result positionally, so
`select(['a', 'a'])` produced a `Sections` that emitted section `a`'s messages twice from
`toMessages()` — which also fixes duplicate emission in the `SelectedSections` message compiler.

`MessageStore::select()` no longer treats `''` and `'0'` as "select everything". It tested
`empty($sections)` before distinguishing a string from an array, and `empty()` is true for both
of those strings, so `select('')` and `select('0')` returned every section — including when a
section was literally named `0`. String and array are now checked first; `select([])` and the
no-argument call still return all sections.

`JsonlStorage::save()` is also atomic now — the whole file is buffered and written through a temp
file plus rename, instead of truncate-then-append once per message, so a throw mid-rewrite can no
longer leave a truncated session behind. The replace-the-session contract is unchanged and
deliberate; only the crash window closed.

## Merging and Parsing Messages No Longer Loses Data Silently

Four places where `packages/messages` produced plausible-looking wrong data rather than an error.

**`toMergedPerRole()` dropped tool calls.** Collapsing a run of same-role messages built a fresh
`Message` and copied only content into it, so tool calls, tool results, name, id, and parentId
were discarded. This reached the wire: the Perplexity golden-request fixture had to be
rebaselined because the merged assistant message carried no `tool_calls` at all and the tool
message had no `tool_call_id`. Merging now starts from the message itself and folds via the new
`Message::withMergedFrom()`, which appends content, concatenates tool calls, keeps the target's
identity, and *throws* if either side carries a tool result — a tool result is bound to one tool
call id, so folding two would silently drop that binding. Runs are split at tool-result
boundaries instead.

**Single-part content arrays were shredded.** `Content::fromAny(['type' => 'image_url',
'image_url' => [...]])` produced two bogus text parts instead of one image part, and reached
providers with no error. A content-parts collection is always a list, so a keyed array must be a
single part; it now routes to `ContentPart::fromArray()`.

**Message arrays without a `content` key had their field values read as content.**
`Message::fromArray(['role' => 'user', 'name' => 'bob'])` produced content
`[text: "user", text: "bob"]`, because the all-values-are-strings arm was tested before the
message-shape arm. The order is reversed; that input now yields empty content and a correctly
handled `name`. The common `['role' => ..., 'content' => ...]` shape was already right and stays
right.

**`getPath()` hung on a parentId cycle** in both backends — an unterminated `array_unshift` loop,
reachable from a hand-built self-parented message or a corrupt session file. Both now track
visited ids and return the partial root-first path.

`ResponseValidationFailed` also gained an `errorMessage` payload key on both the exception and
invalid-result paths; the invalid-result dispatch previously carried no top-level readable
message.

## Events Are Not Built When Nothing Listens

`ListenerGate` is the single definition of whether an emitter should construct an event at all.
Constructing one costs roughly 0.9µs before any payload is assembled, since the base event
generates a UUID and a timestamp, and payload arrays cost more on top. Emitters on hot paths
resolve the gate once and skip both.

Fail-open is contractual: a dispatcher that cannot report its listeners is assumed to be
listening and receives every event, so a plain PSR-14 dispatcher never loses an event to this
optimization.
