Skip to content

Implement proposal 0025: tool_choice + absorb 0026 §8.X template#70

Merged
chris-colinsky merged 6 commits into
mainfrom
feature/0025-tool-choice
May 25, 2026
Merged

Implement proposal 0025: tool_choice + absorb 0026 §8.X template#70
chris-colinsky merged 6 commits into
mainfrom
feature/0025-tool-choice

Conversation

@chris-colinsky
Copy link
Copy Markdown
Member

@chris-colinsky chris-colinsky commented May 25, 2026

Summary

Implements spec proposal 0025 (tool_choice on the Provider interface) and absorbs proposal 0026 (§8.X wire-format mapping subsection template — purely textual) in openarmature-python. Bumps the spec submodule pin from v0.19.0 to v0.20.1.

Provider.complete() gains an optional tool_choice: ToolChoice | None = None parameter — one of "auto", "required", "none", or a ForceTool(name=...) record. Pre-send validation covers the three §5 rules (required/empty-tools, ForceTool/empty-tools, ForceTool.name not in tools) and raises ProviderInvalidRequest (§7's existing category; no new error category). The OpenAI wire mapping in _build_request_body translates the spec shape onto OpenAI's body per the §8.1.1 table: string literals pass through verbatim; ForceTool renames type to "function" and nests the name under a function sub-object. When tool_choice is None (the default) the wire field is omitted entirely — pre-0025 callers see no wire-shape change.

ForceTool is a frozen Pydantic model with type: Literal["tool"] = "tool" matching the spec's discriminator at the type system level; the wire-side rename (toolfunction) happens inside the provider, not at the type.

Spec alignment

  • Spec proposals: 0025 tool_choice (v0.20.0) and 0026 §8.X template (v0.20.1). Both Accepted on 2026-05-24.
  • Conformance fixtures absorbed: llm-provider 029 (modes: auto / required / none / default), 030 (force-specific), 031 (validation failure modes — required with empty tools, force-specific with empty tools, force-specific with name not in tools).
  • 0026 zero-impl: the existing OpenAI §8.1 mapping in OpenAIProvider is the template's reference shape — the proposal explicitly says "§8.1 already follows the recommended template by definition." Pin bump only.

What's new

Public API:

  • openarmature.llm.ForceTool — frozen Pydantic model (type: Literal["tool"] + name: str).
  • openarmature.llm.ToolChoice — type alias: Literal["auto", "required", "none"] | ForceTool.
  • openarmature.llm.validate_tool_choice — standalone validator for the three §5 rules; useful for third-party Provider implementations.
  • Provider.complete(..., tool_choice: ToolChoice | None = None) -> Response — extended signature.

Behavior changes:

  • OpenAIProvider.complete() accepts and forwards tool_choice. The wire body emits the spec's discriminated-union value per §8.1.1.
  • Pre-send validation: validate_tool_choice runs after validate_tools so name-membership checks see a structurally valid tools list.
  • Third-party Provider implementations that don't add the parameter still structurally satisfy the Protocol; their wire path silently drops tool_choice (per spec, whether a provider honors tool_choice is a per-provider concern).

Test plan

  • uv run pytest tests/ — 795 pass, 74 skipped, 0 failures.
  • uv run pytest tests/unit/test_tool_choice.py -v — 15 new unit tests covering validation rules + ForceTool shape + wire mapping.
  • uv run pytest tests/conformance/test_llm_provider.py -k tool-choice — 3 new conformance fixtures (029, 030, 031) all pass.
  • Read src/openarmature/llm/messages.py ForceTool + ToolChoice definitions.
  • Read src/openarmature/llm/provider.py validate_tool_choice and the extended Protocol signature.
  • Read src/openarmature/llm/providers/openai.py complete() + _build_request_body wire mapping.
  • Render mkdocs serve and review the new "Controlling tool-call behavior with tool_choice" subsection under docs/concepts/llms.md.
  • Verify CHANGELOG Unreleased entries describe the change accurately.

Out of scope (deferred)

  • Multi-tool forced choice ({type: "tool", names: [X, Y]}) — spec alternatives §; no current providers support it on the wire.
  • A force_tool(name) helper instead of the discriminated union — spec alternatives §; explicit-API-over-sugar.

Provider.complete() gains an optional tool_choice parameter — one of
"auto", "required", "none", or a ForceTool record — constraining the
model's tool-calling behavior. Pre-send validation routes the three
§5 failure modes through ProviderInvalidRequest (§7's existing
category; no new category per the proposal's framing).

ForceTool is a frozen Pydantic model with type: Literal["tool"]
matching the spec discriminator. The OpenAI wire mapping in
_build_request_body translates the spec shape to OpenAI's body per
§8.1.1: string literals pass through verbatim; ForceTool renames
type to "function" and nests the name under a function sub-object.
None / omit preserves pre-0025 behavior — the field is absent on
the wire and the provider's own default applies.

15 unit tests cover the three validation rules, ForceTool shape
constraints (frozen, extras-forbid, Literal type), and the wire
mapping rows from §8.1.1.
Adds assert_tool_choice_absent matcher mirroring the existing
response_format_absent pattern, _build_tool_choice parser handling
both YAML shapes (string for the three modes, dict for the
ForceTool record form), and tool_choice passthrough on both call
sites (raises path and success path).

The expected_wire_request_checks dispatcher gains a
tool_choice_absent key for fixture 029's default case, where the
wire body MUST omit the field entirely (preserves pre-0025 caller
behavior — the OpenAI provider's own default applies).

LlmCallSpec uses _AllowExtras so tool_choice parses without an
explicit pydantic field; no fixture-parsing model changes needed.
Submodule pin advances from v0.19.0 to v0.20.1, absorbing proposal
0025 (tool_choice, v0.20.0) and proposal 0026 (§8.X wire-format
mapping subsection template, v0.20.1). 0026 is purely textual; the
existing OpenAI §8.1 mapping is the template's reference shape so
no python module-level work is needed. Runtime spec_version pins in
pyproject and __init__ updated to match; smoke test asserts v0.20.1.

CHANGELOG Unreleased section gains tool_choice + ForceTool +
ToolChoice + validate_tool_choice entries under Added; the
Provider.complete() signature extension noted under Changed;
cumulative pin-bump summary updated to v0.17.0 -> v0.20.1 across
six spec versions absorbed this cycle.

docs/concepts/llms.md gains a "Controlling tool-call behavior with
tool_choice" subsection under Tool calling, covering the four modes,
the three pre-send validation rules, and the cross-provider caveat
(not all providers honor tool_choice).
Copilot AI review requested due to automatic review settings May 25, 2026 23:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements spec proposal 0025 by adding tool_choice support to the Provider.complete() API, validating the constraint pre-send, and mapping it onto the OpenAI Chat Completions wire format; also updates the pinned spec version to v0.20.1 (absorbing proposal 0026’s §8.X template text).

Changes:

  • Extend Provider.complete() with tool_choice: ToolChoice | None plus shared validation (validate_tool_choice) and public types (ForceTool, ToolChoice).
  • Add OpenAI wire mapping for tool_choice (omit when None; map ForceTool to {type:"function", function:{name:...}}).
  • Add unit + conformance harness coverage and bump spec version pins/docs/changelog.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/test_tool_choice.py New unit tests for ForceTool, validate_tool_choice, and OpenAI wire mapping behavior.
tests/test_smoke.py Updates asserted pinned spec version to 0.20.1.
tests/conformance/test_llm_provider.py Extends fixture parsing and provider calls to support tool_choice.
tests/conformance/harness/wire.py Adds assert_tool_choice_absent wire-body check helper.
tests/conformance/harness/init.py Exports assert_tool_choice_absent.
src/openarmature/llm/providers/openai.py Adds tool_choice parameter, validates it, and maps it to OpenAI request body.
src/openarmature/llm/provider.py Extends Provider Protocol signature and introduces validate_tool_choice.
src/openarmature/llm/messages.py Introduces ForceTool model and ToolChoice type alias; exports them.
src/openarmature/llm/init.py Re-exports ForceTool, ToolChoice, and validate_tool_choice.
src/openarmature/init.py Bumps __spec_version__ constant to 0.20.1.
pyproject.toml Bumps [tool.openarmature].spec_version to 0.20.1.
docs/concepts/llms.md Documents tool_choice usage and semantics.
CHANGELOG.md Documents new API, behavior, and spec pin bump.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/openarmature/llm/provider.py
Comment thread CHANGELOG.md Outdated
The "future Anthropic / Gemini providers will follow the §8.X
template" framing references unproposed spec work. Out-of-scope
items belong in PR descriptions scoped to this PR; speculative
future capabilities and proposal numbers don't belong in the
release-notes record.
validate_tool_choice now rejects unknown string literals at the API
boundary. Pyright catches the well-formed path via the ToolChoice
Literal alias, but Python doesn't enforce Literal at runtime — an
untyped caller passing `tool_choice="bogus"` previously fell through
to the wire and yielded a hard-to-debug provider-side 4xx. Now
surfaces as ProviderInvalidRequest with the allowed values listed.

CHANGELOG entry on the Provider.complete() signature change reworded
to be technically accurate: third-party Provider implementations
MUST accept the new parameter for Protocol conformance under strict
typing (and to avoid TypeError at the call site); they MAY ignore it
in their wire-body emission, which is how "provider doesn't honor
tool_choice" looks at the impl level. The "silently drops" framing
was wrong — accept-and-ignore is the correct pattern.
Copilot AI review requested due to automatic review settings May 25, 2026 23:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comment thread src/openarmature/llm/provider.py
validate_tool_choice now rejects values that are neither a string
nor a ForceTool instance at the API boundary. A caller hand-building
a dict like {"type": "tool", "name": X} (instead of constructing a
ForceTool) previously fell through validation and the raw dict
ended up on the wire — the spec→wire rename only runs on actual
ForceTool instances, so OpenAI got the wrong request shape and
rejected with a hard-to-debug 4xx.

Surfaces at the boundary with the expected type listed:
ProviderInvalidRequest('tool_choice must be one of "auto" /
"required" / "none" or a ForceTool instance, got <type>').

Adds a unit test covering dict, int, and list inputs. The
reportUnnecessaryIsInstance suppression on the ForceTool isinstance
check is load-bearing — pyright narrows the type via the alias,
but the entire point of this validator is to defend against
untyped callers that bypass static narrowing at runtime.
@chris-colinsky chris-colinsky merged commit ac822d3 into main May 25, 2026
6 checks passed
@chris-colinsky chris-colinsky deleted the feature/0025-tool-choice branch May 25, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants