[Platform] Complete structured output instance mode - #2448
marco-jouwweb wants to merge 3 commits into
Conversation
|
Thanks @marco-jouwweb - makes sense as a feature, I'd say. What do you think of converting this to an option that gets interpreted in the listener and does some post-processing of the schema there? the interface could stay the same. brings in the issue of "how to detect if we should drop something" 🤔 |
Good to hear you are positive about the idea 🙂 About making it an option that gets interpreted by a listener; I am not fully sure what you mean. Do you mean that we pass it as new variable through I don't know if I'm convinced by the post-processing listener suggestion, though. It seems like symptom treatment to first describe the class itself, just to alter it after the fact to represent the instance. I can see that becoming hard to manage real quickly. My gut feeling would say to properly describe instances from the start instead, so we don't have to solve that through post-processing of the JSON schema. Though I am not completely sure this is what you are talking about 🤔 |
chr-hertel
left a comment
There was a problem hiding this comment.
I think it makes sense to have that feature directly available with an option like this:
$platform->invoke($model, $messages, [
'response_format' => new City(name: 'Berlin'),
'missing_data_only' => true,
]);but maybe missing_data_only is not that great of a name 😬
this would require tho to ship the code, and currently the documented MissingPropertiesResponseFormatFactory also only does post-processing of a decorated factory - but we need to change the interface for that.
If you'd promote that post-processing, that we already have, to be directly or triggered within the PlatformSubscriber, we don't need to change the interface :)
I kinda trusted Claude a bit too much here, my bad. The example does encourage the thing I am not fond of, you are right. My misunderstanding was thinking that plumbing was the only missing part. It is not: even with the instance reaching the factory, the shipped factory still describes the full class. So the PR as it stands is an extension point, not the feature you describe. That said, I still think post-processing is not the right place for it. Deciding which properties get described is a responsibility of the describer chain, and the chain already does exactly this kind of narrowing for APIAn explicit carrier option instead of sniffing the type of $platform->invoke($model, $messages, [
'response_format' => City::class,
'populate_instance' => $city,
]);Passing the instance as Plumbing
public function create(string $responseClass, array $context = []): array;
Describer
Nested objectsFor the fix to be complete, nested objects need the same treatment. Today
"How to detect if we should drop something"The describer has the reflector, the type and the read accessor at hand, so the rule can be simple and explicit: a property is to be filled when it is uninitialized or Edge case in either approach: when every property is filled, the schema collapses to nothing. I'd throw there, since sending a request with an empty schema is almost certainly not intended? Why not post-process in the subscriber
This increases the scope of the PR significantly. Could you please LMK what you think about the proposed solution, and if you'd like me to put all of this in the current PR or if you'd like it spread out over multiple smaller PR's? Thanks for your time! |
1cbcd9f to
e96f524
Compare
|
@chr-hertel I did the full implementation & updated the title and description. This extended the scope significantly, though most LOC are just tests. Let me know if you need anything from me to make reviewing more bearable :-) but I don't expect that being necessary. |
chr-hertel
left a comment
There was a problem hiding this comment.
Still not convinced by that approach, sorry - let me explain:
We're adding a new feature, rather a specific one - maybe potential to grow, for now rather limited tho, but still meaningful. we could maybe make those cases up, but we don't have them yet.
we have the benefit that this feature only needs to go into an internal layer - we can be lazy. the only opinion people will have is about "how do i use it?" and maybe "how can i change it's behavior?" - the second question we're dropping for now - use-case based feature might change that.
with the current implementation this has impact one extension point and to unrelated implementations - so the change spreads.
compare the footprint of #2570, where the feature gets hooked in where it is used, but is isolated. IMO that makes it simpler to iterate and rework. (haven't tested the schema filter class tho yet.)
do you think i miss something here?
| $context = []; | ||
| if (null !== $this->objectToPopulate) { | ||
| $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $this->objectToPopulate; | ||
| // Populate nested objects in place too, so a partial answer keeps the values they already hold |
There was a problem hiding this comment.
| // Populate nested objects in place too, so a partial answer keeps the values they already hold |
|
|
||
| if (null !== $this->objectToPopulate) { | ||
| $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $this->objectToPopulate; | ||
| // Populate nested objects in place too, so a partial answer keeps the values they already hold |
There was a problem hiding this comment.
| // Populate nested objects in place too, so a partial answer keeps the values they already hold |
| * } | ||
| */ | ||
| public function create(string $responseClass): array; | ||
| public function create(string $responseClass, array $context = []): array; |
There was a problem hiding this comment.
to this is mostly the same contract change like before - not overloading the first parameter, but hiding the instance in the second. and array $context is more fuzzy and only used internally, right? i mean we'd have exactly the same functionality with create(string $responseClass, ?object $instanceToPopulate = null)
|
@chr-hertel Thanks for putting #2570 together, it made the comparison a lot more concrete than arguing in the abstract 🙂 I've thought about it some more and I want to split my answer in two, because I think we're actually disagreeing about one thing only. The signature: you're right
One thing I want to flag so it's a conscious decision and not an accident: the day we want a per-call I'd also take over the two rules #2570 gets right and mine doesn't: dropping properties that aren't writable on the instance (readonly / constructor-only), and stripping The nested The placement: I still think this belongs in the describer chainNot because post-processing is ugly, but because of what the filter has to know. I put two failing tests on top of your branch in #2581 (illustration only, not meant to be merged):
I don't think On "how can I change its behaviour": describers are already the customization point people have, so a future Proposal
That gets the footprint close to #2570 while keeping the one thing I think is worth the interface change. Would that work for you? If yes I'll rework this PR accordingly. I think we've gave this quite some thought already and I don't want to take too much of your time, so whatever you decide I'll accept. Its a complex feature, thanks for the effort! |
|
Love your endurance for the topic and your approach - really fun to wrangle around a topic and challenge ideas - thanks for that! The new contract idea is not that "ugly" anymore - sure, give it a try. I don't have the chance to go after #2581 - in the morning at least. |
868fdb9 to
74ba130
Compare
|
Woah, I messed up a rebase and this caused a billion changes and this somehow automatically requested reviews from related code owners which was never my intention. Sorry for that! Fixing the Git spaghetti as we speak. |
74ba130 to
3f885d7
Compare
`response_format` accepts an existing object instance, used as the serializer's `object_to_populate`, but the schema half of the feature never saw it: the JSON schema was always derived from the class, so the model was asked for every property again, including the ones the instance already holds. Add a `missing_properties_only` option. `PlatformSubscriber` hands the instance to `ResponseFormatFactoryInterface::create()` through a new `?object $instanceToPopulate` argument, and the shipped factory passes it into `Contract\JsonSchema\Factory::buildProperties()` as the `populate_instance` describer context, so the decision which properties to describe is made by the describers themselves rather than by post-processing a finished schema. A property counts as missing when it is uninitialized, `null` or an empty array, and it can be written onto the instance; nested objects are narrowed the same way, lose `null` from their type and are populated in place through `DEEP_OBJECT_TO_POPULATE`. `PropertySubject` now carries the describer context, which `TypeInfoDescriber` and `MethodDescriber` propagate into nested object schemas. That also closes a gap for `serializer_groups`, which previously only reached discriminated sub-schemas. Co-authored-by: Cursor <cursoragent@cursor.com>
3f885d7 to
ef6761f
Compare
Happy to hear, likewise! 🙂 Nice to see how you handle PRs in great depth, much appreciated. Edit: I thought about what the current contract means for a use case I have, and it makes me doubt We use domain objects that predate LLMs directly as structured output; a parallel family of DTOs did not survive the volume. We scope the fields the model sees with $agent->call($messages, [
'response_format' => $page,
'serializer_groups' => ['ai-fill'],
]);The describer half exists:
That is the sibling of Which brings me back to the signature. With
What do you prefer? If the first, I prepare the signature here and send |
|
okay, interesting, and you're right - we should zoom out: i want to control which properties are open for generation! maybe only missing ones, maybe a strategy like me defining groups, etc ... makes sense, but not easier right away 🤔 I think my main concern about not using the current factory-describer is about mixing data, behavior and schema. so the schema doesn't change by the data, it's only which parts we want to use from that - that's why my brain is pulling me into that filter idea over and over. encapsulating those concerns help with managing the complexity - unless there is a meaningful synergy in the first place - what part of your hypothesis is. need to let that sink in for now and will come back to this ... |
I agree, take all the time you need to let this sink in. I have trouble wrapping my mind around the complete picture as well 😅 I wanted to share the idea below, but please don't feel pressed to answer this today. For when you are ready: I share your concern about mixing data, behaviour and schema. It blurs the lines and gets hard to follow quickly, so we should separate those properly. I understand why that makes the post-processing idea tempting again, but I still think it bites us in the long run (#2581), so here is a way to get the separation without it. My first thought was to abstract the what-is-open logic ( interface PropertySelectorInterface
{
/** Whether the model may generate this property of the object being described. */
public function isOpen(ObjectSubject $object, PropertySubject $property): bool;
/** The selector for a nested object property, null to describe the nested class in full. */
public function forProperty(PropertySubject $property): ?self;
}On its own that only moves the problem around: the runtime data would still be consulted inside a describer, a well-wrapped version of the same smell. So the selection logic has to leave the describers. The natural call-site is the public function describeObject(ObjectSubject $subject, ?array &$schema): iterable
{
$selector = $subject->getContext()[Factory::CONTEXT_SELECTOR] ?? null;
$schema = $required = [];
foreach ($this->objectDescribers as $describer) {
foreach ($describer->describeObject($subject, $schema) as $property) {
if ($selector instanceof PropertySelectorInterface) {
if (!$selector->isOpen($subject, $property)) {
continue;
}
// The nested selector travels in the property's context; null describes the nested class in full
$property = $property->withContext([Factory::CONTEXT_SELECTOR => $selector->forProperty($property)] + $property->getContext());
}
$this->describeProperty($property, $schema['properties'][$property->getName()]);
// ...
}
}
}( What this gives us:
To be upfront: the final schema of a call still depends on the instance, that is the feature. But the dependency is confined to one step in the orchestrator and one class, and the describers never see it. |
Introduce Contract\JsonSchema\Selector\PropertySelectorInterface, applied by the Describer orchestrator through the Factory::CONTEXT_SELECTOR context before a property is described, so the describers stay a function of the class and the instance never enters them. MissingPropertiesSelector holds the instance and the rule that was in PropertyInfoDescriber; the nested selector travels in the property context via PropertySubject::withContext(). The orchestrator also drops a nested object populated in place when nothing on it is open, and strips null from its type, where TypeInfoDescriber previously checked for the instance. ResponseFormatFactory wraps the instance in the selector. Behaviour and tests are unchanged.
| $schema = $required = []; | ||
| $selector = $subject->getContext()[Factory::CONTEXT_SELECTOR] ?? null; | ||
| if (!$selector instanceof PropertySelectorInterface) { | ||
| $selector = null; |
There was a problem hiding this comment.
Possibly throw here? Dunno, seems like a reason to fail fast because not narrowing can silently affect the output & costs. Better to fail loud & early?
| * an existing instance never calls its constructor. A nested object is decided by its own properties, through the | ||
| * selector returned for it. | ||
| * | ||
| * @author Marco van Angeren <marco@jouwweb.nl> |
There was a problem hiding this comment.
Claude decided to leave me credits, feel free to remove it everywhere x]
|
@chr-hertel Updated the PR to reflect my last comment: the selection now lives in the That also changed the contract: with the instance wrapped in a selector, Either shape works for me, |
With the instance wrapped in a MissingPropertiesSelector, the factory did nothing with it but wrap it, so the `?object $instanceToPopulate` parameter is replaced by the describer context itself: `create(string $responseClass, array $context = [])`, the contract `Contract\JsonSchema\Factory::buildProperties()` already has. PlatformSubscriber builds the selector and passes it under Factory::CONTEXT_SELECTOR; a per-call `serializer_groups` option can later travel the same way without another change to the interface.

Problem
response_formataccepts an existing object instance, and the Platform uses it as theserializer's
object_to_populate, so the model's answer is written onto that veryinstance (
Populating Existing Object Instancesin the Platform docs).The schema half of that feature never sees the object.
ResponseFormatFactoryInterface::create()takes only aclass-string, so the JSONschema is always the open-ended shape the class allows, never the shape the instance
actually needs. The model is asked to invent values the instance already holds: wasted
tokens, and an invitation to contradict data the application is sure about.
The feature
A
missing_properties_onlyoption. With an instance asresponse_format, the schema isbuilt from what that instance still lacks rather than from everything its class allows:
The request carries a schema of
population,countryandmayor.nameis absent,because the application already knows the city is Berlin. Without the option the same call
sends all four properties and asks the model to restate a value it was given.
A property is left to the model when it is uninitialized,
nullor an empty array.Every other value, including
'',0andfalse, is taken as given and left out of theschema. A nested object is decided by its own properties rather than by its presence: one
with nothing missing is left out entirely, a
nullone is described in full, and apartially filled one is described with only its own gaps and populated in place. A
non-empty collection is left out, an empty one is described with its full item schema.
Without the option nothing changes: an instance still describes its whole class, exactly
as before.
How it works
The decision is made while the schema is built, not by post-processing a finished one,
so it composes with everything the describers already do:
PlatformSubscriberconsumes the option and hands the instance to the factory as$context['populate_instance']. The option itself is never forwarded to the provider.ResponseFormatFactoryInterface::create()gains anarray $context = []parameter andforwards it to
Contract\JsonSchema\Factory::buildProperties(), which already took adescriber context for
serializer_groups. This is the BC break.PropertyInfoDescriberreads each property off the instance and skips the ones alreadyfilled, recursing into nested objects with the nested value as the new instance.
Values are read through the backing property by reflection regardless of visibility, so a
getter that would throw on an uninitialized property is never invoked; only a virtual
property without backing property falls back to its getter.
Using the option with anything but an instance as
response_format— a class name, a rawschema, or nothing at all — throws an
InvalidArgumentException, as does an instance withno missing properties left, before any request is sent.
Two fixes that came with it
TypeInfoDescriberandMethodDescriberbuilt nested object schemas with an empty context, so
serializer_groupssilentlystopped applying one level down.
PropertySubjectnow carries the context and bothdescribers propagate it. This is a pre-existing bug, independent of this feature.
instance now uses
DEEP_OBJECT_TO_POPULATE, in the bufferedResultConverterand inPartialObjectStreamListeneralike. Without it a partial answer for a nested objectwould hand back a fresh instance and drop the values it already held — which is exactly
what this feature produces.
BC impact
BC Breaklabel +UPGRADE.mdentry. Callers are unaffected. Implementors ofResponseFormatFactoryInterfacemust add the parameter, otherwise PHP raises adeclaration-compatibility fatal error:
final class MyResponseFormatFactory implements ResponseFormatFactoryInterface { - public function create(string $responseClass): array + public function create(string $responseClass, array $context = []): array { // ... } }An implementation that accepts the parameter but ignores it keeps working and silently
disables
missing_properties_only. In this repository the only implementations areResponseFormatFactoryand a test double, both updated here.ai-bundleregisters thefactory as a service and aliases the interface; nothing to change there.
Alternatives considered
(a) Widen the parameter to
create(string|object $response). An earlier revision ofthis PR. Equally a break for implementors, but it stops at the factory: the instance would
still have to be smuggled into
buildProperties(), which is where the decision isactually made. The context array reaches the describers, composes with
serializer_groupsinstead of sitting beside it, and keeps the public option a plain boolean.
(b) A separate
createForInstance(object $response). Also breaks every implementor,and leaves two methods to keep in sync for one concept.
(c) Post-process the finished schema. Cannot see what the describers saw, so nested
objects and recursion have to be re-derived from the schema rather than from the values.
(d) Do nothing; document decorating the factory. The status quo, and it only works by
keeping mutable per-invocation state on a service, which is not safe to recommend.
Follow-up (deliberately not here)
Contract\JsonSchema\Provider\SchemaProviderInterface::getSchemaFragment()still cannotdepend on the instance:
SchemaAttributeDescribercalls it with the static array from#[Schema(context: ...)]. Letting a provider fragment see the runtime subject raises itsown design questions and is better argued separately.