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():
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:
cognesy/instructor-agents, StructuredOutputPolicy::withConfigBuilder() is accordingly
renamed to withConfig() and now takes and returns a StructuredOutputConfig.
Legacy Inline Prompt Settings Removed
The legacyRequestMaterializer 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 bySyncExecutionDriver 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:
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 underCognesy\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:
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:
->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 throwsRuntimeException("Message not found: ...")in both storage backends. It previously created a session with no messages and aleafIdpointing at nothing, so the nextappend()stamped a danglingparentIdonto a real message.JsonlStorageperforms the check before creating the file, so a rejected fork leaves no orphan behind.ToolCalls::fromArray()throwsInvalidArgumentExceptionnaming the offending index for a bare string at a list position, where it previously surfaced a rawTypeErrorfrom insideToolCall::__construct(). Catch the new type if you were catchingTypeErrorhere.ContentPart::toString()returns''instead of raising aTypeErrorwhen thetextfield 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, whileCounter, 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
Anagent-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:
stringClipLengthdefaults to2048instead of0(unbounded), so a single large response body cannot dominate a log file.redactKeysdefaults to a built-in list. Values under matching keys are replaced with[redacted]before being written, and matching ignores case and separators, soauthorizationalso coversProxy-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 throughquarantined(), 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 wherepackages/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.