Follow-up parked from the v0.13 tool-choice work. The feature currently ships toolChoice as an option on ProviderInterface::chat(), validated by the internal PapiAI\Core\ToolChoice normaliser (which throws on unenforceable values, never silently drops), with the options shape defined once as a @psalm-type ChatOptions that providers import.
That is deliberately the option-bag design. The richer, ISP-clean design is deferred to here:
1. Segregate tool-calling (ISP)
ProviderInterface is a fat interface (chat + stream + supportsTool() + supportsVision() + supportsStructuredOutput()). Introduce a segregated ToolCallingProviderInterface carrying the tool concerns (tools, tool-choice semantics), mirroring how Embedding/Image/TextToSpeech/Video are already separate capability interfaces. Consumers type against it for a static guarantee instead of the runtime supportsTool() probe; the Agent switches to instanceof.
2. Capability query for graceful consumers
Add a single capability query (e.g. supportsToolChoice(): bool) on the segregated interface so consumers can degrade gracefully by choice, without catching. Not added to the base contract now because a supports*() method there forces an all-providers-at-once change; it belongs on the segregated interface.
3. First-class value objects
Promote the options bag and tool-choice from a Psalm type / internal validator to public value objects: a ChatOptions object and a ToolChoice value object with public factories (ToolChoice::auto(), ::none(), ::required(), ::tool('name')), so callers build a validated, typed choice instead of a string|array.
Constraints
- Breaking change to the base contract; touches every chat provider. Do it backward-compatibly (keep
supports*() as deprecated shims) or as a clean v1.0 break.
- Best sequenced with the planned v1.0 interface audit.
Follow-up parked from the v0.13 tool-choice work. The feature currently ships
toolChoiceas an option onProviderInterface::chat(), validated by the internalPapiAI\Core\ToolChoicenormaliser (which throws on unenforceable values, never silently drops), with the options shape defined once as a@psalm-type ChatOptionsthat providers import.That is deliberately the option-bag design. The richer, ISP-clean design is deferred to here:
1. Segregate tool-calling (ISP)
ProviderInterfaceis a fat interface (chat + stream +supportsTool()+supportsVision()+supportsStructuredOutput()). Introduce a segregatedToolCallingProviderInterfacecarrying the tool concerns (tools, tool-choice semantics), mirroring howEmbedding/Image/TextToSpeech/Videoare already separate capability interfaces. Consumers type against it for a static guarantee instead of the runtimesupportsTool()probe; theAgentswitches toinstanceof.2. Capability query for graceful consumers
Add a single capability query (e.g.
supportsToolChoice(): bool) on the segregated interface so consumers can degrade gracefully by choice, without catching. Not added to the base contract now because asupports*()method there forces an all-providers-at-once change; it belongs on the segregated interface.3. First-class value objects
Promote the options bag and tool-choice from a Psalm type / internal validator to public value objects: a
ChatOptionsobject and aToolChoicevalue object with public factories (ToolChoice::auto(),::none(),::required(),::tool('name')), so callers build a validated, typed choice instead of astring|array.Constraints
supports*()as deprecated shims) or as a clean v1.0 break.