Skip to main content
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():
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:
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:
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.
CanValidateResponse::validate() gains an optional trailing ?PhaseTelemetryContext:
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:
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:
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.