feat(llm): route reasoning models to reasoning-aware provider clients #106
@@ -17,6 +17,14 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm
|
||||
|
||||
**Module:** `src/cleveractors/agents/timeout_policy.py` (`TimeoutPolicy`), `src/cleveractors/agents/tool.py` (`ToolAgent._generic_timeout_policy`, `._shell_timeout_policy`, `._execute_shell_command`, `._http_request_tool`), `src/cleveractors/agents/llm_tools.py` (`_BUILTIN_TOOL_SCHEMAS["shell"]`, `["http_request"]`). BDD: `features/shell_timeout_override.feature`. Robot: `robot/shell_timeout_override.robot`. Benchmark: `benchmarks/timeout_policy_benchmark.py`.
|
||||
|
||||
- **Reasoning-Aware Provider Routing for `type: llm` Agents (issue #101)** (`agents/llm_client.py`, `agents/llm_imports.py`, `agents/llm_reasoning.py`): Adds an optional `reasoning` boolean field (§4.4) to the LLM agent configuration so a non-native provider (any provider outside `openai`/`anthropic`/`google`) can be routed to a reasoning-aware chat model that preserves and round-trips the provider's `reasoning_content` across a multi-turn tool-call loop.
|
||||
|
||||
Previously, every non-native provider was routed unconditionally to a bare `langchain_openai.ChatOpenAI(base_url=...)` client, which silently drops `reasoning_content` on both the response and request legs. Reasoning / "thinking" models behind an OpenAI-compatible endpoint that require the reasoning block to be echoed back on the assistant turn preceding a tool result therefore rejected the follow-up call with a non-retryable `400 ... reasoning_content ... must be passed back` error. Setting `reasoning: true` on such an agent now constructs `cleveractors.agents.llm_reasoning.ReasoningChatModel` (a thin `langchain_deepseek.ChatDeepSeek` subclass) instead, which re-emits the model's `reasoning_content` on the assistant turn of the following request. `ReasoningChatModel` is resolved lazily through the existing `langchain-deepseek`-guarded import machinery, so its absence degrades to a clear `ConfigurationError` rather than an import crash. When `reasoning` is absent or `false`, or the provider is native, routing and request payloads are unchanged.
|
||||
|
||||
See `docs/adr/ADR-2036-reasoning-aware-provider-routing.md` for the full set of design decisions (client selection strategy, response/request-leg responsibilities, and why stock `ChatDeepSeek` alone is insufficient).
|
||||
|
||||
**Module:** `src/cleveractors/agents/llm_client.py` (`build_chat_model`, `_build_from_credentials`), `src/cleveractors/agents/llm_imports.py` (`resolve_class_ref`), `src/cleveractors/agents/llm_reasoning.py` (`ReasoningChatModel`). BDD: `features/reasoning_provider_routing.feature`. Robot: `robot/reasoning_provider_routing.robot`.
|
||||
|
||||
- **Skill Package Schema and Agent-Side Skill Loading (issue #88)** (`agents/skills.py`, `agents/skill_schema.py`, `agents/skill_resolution.py`, `agents/factory.py`, `agents/llm.py`, `agents/tool.py`, `agents/llm_tools.py`, `runtime.py`, `runtime_dispatch.py`, `core/application.py`): Adds a normative Skill package schema (Package Registry Standard §16) aligned with the open [agentskills.io](https://agentskills.io/specification) Agent Skills format, and a new optional `skills` field on `type: llm` agent configs that resolves, validates, and loads Skill packages through the existing `cleveractors.registry` client.
|
||||
|
||||
A `skill`-type package is a single YAML mapping with required `name`, `description`, and `instructions`, plus optional `license`, `compatibility`, `metadata`, `allowed_tools`, and `resources` (bundled files, UTF-8 or base64-encoded, validated as well-formed at load time) — see `docs/actor-registry-standard.md` §16 for the full schema. `SkillLoader` resolves each `skills` reference (via `registry:`, `ID:`, or `local:` schemes, mirroring the existing template package-reference resolution path in `cleveractors.templates.base._resolve_package_ref`), validates it with `SkillValidator` (rejecting duplicate skill names across a `skills` list) and returns an augmented config with a discovery catalogue appended to `system_prompt`, a synthesized `skill` activation tool appended to `tools` (recognizing an already-declared `skill` tool in either shorthand or normalized OpenAI function-calling form, so it is never duplicated), and the resolved catalogue under an internal `_loaded_skills` key. A skill's content-addressed `package_id` is identical regardless of which reference scheme resolved it, and any reference that cannot be resolved — including an `ID:` reference absent from the local store's identity map — fails with a clear registry error rather than a misleading validation error. Both `SkillLoader`/`SkillReferenceResolver` and `AgentFactory` (`acreate_agent`) expose matching sync and async resolution paths, so a `registry:` reference resolves correctly whether agent creation runs from plain sync code or from the async runtime dispatch layer; every reference in a `skills` list shares one `PackageContentResolver` per agent-creation call — built unconditionally regardless of whether a local store or registry API key is configured, and closed once that call completes, including when agent creation itself fails. `Skill.to_context_dict()` surfaces `license`, `compatibility`, `metadata`, and `allowed_tools` alongside `name`/`description`/`instructions`/`resources`; base64-encoded resources that are not valid UTF-8 (genuine binary assets) decode safely instead of raising.
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# ADR-2036: Reasoning-Aware Provider Routing — Specification Extensions
|
||||
|
||||
**Status:** approved
|
||||
|
||||
**Date:** 2026-08-04
|
||||
|
||||
**Author:** Luis Mendes (CoreRasurae)
|
||||
|
||||
**Issue:** #101 — Route reasoning models to reasoning-aware provider clients so `reasoning_content` round-trips
|
||||
|
||||
**Extends:** ADR-2028 (Extended Provider Routing)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-2028 (Extended Provider Routing) routes every non-native provider — the
|
||||
generic `openai_compatible` extension and every named additional provider
|
||||
(`groq`, `fireworks`, `together`, `mistral`, `openrouter`, …) — through a bare
|
||||
`langchain_openai.ChatOpenAI(base_url=..., api_key=...)` client. The routing
|
||||
logic lives in
|
||||
`cleveractors.agents.llm_client.build_chat_model._build_from_credentials`
|
||||
(commit `882336c`).
|
||||
|
||||
`langchain-openai` (currently 1.4.x) explicitly targets the **official** OpenAI
|
||||
API specification only. Its `BaseChatOpenAI` module docstring states that
|
||||
non-standard response fields added by third-party providers — specifically
|
||||
`reasoning_content` (and `reasoning_details`) — are **not** extracted or
|
||||
preserved, and recommends a provider-specific subclass (e.g. `ChatDeepSeek`)
|
||||
when pointing `base_url` at a reasoning-capable provider.
|
||||
|
||||
Concretely, `langchain_openai` drops `reasoning_content` on **both** legs of a
|
||||
conversation:
|
||||
|
||||
1. **Response leg** — `_convert_dict_to_message` / `_create_chat_result` build
|
||||
the `AIMessage` from `role`, `content`, and `tool_calls` only. The
|
||||
provider's `reasoning_content` is discarded and never lands on the resulting
|
||||
`AIMessage`.
|
||||
2. **Request leg** — `_convert_message_to_dict` serialises an assistant message
|
||||
back to the wire as `role`/`content`/`tool_calls` (and optionally
|
||||
`function_call`/`audio`) only. Even if a `reasoning_content` value were
|
||||
present in `AIMessage.additional_kwargs`, it would not be emitted.
|
||||
|
||||
The multi-turn tool-call loop
|
||||
(`cleveractors.agents.llm.LLMAgent._execute_tool_loop`) appends the returned
|
||||
`AIMessage` verbatim to the running message list and replays it on the
|
||||
follow-up call that carries the tool result. Providers that operate in
|
||||
"thinking mode" and require the reasoning block to be echoed back on that
|
||||
assistant turn reject the follow-up request:
|
||||
|
||||
```
|
||||
Error code: 400 - {'error': {'type': 'invalid_request_error',
|
||||
'code': 'invalid_request_error', 'message': 'Error from provider (Console):
|
||||
Upstream request failed: [invalid_request_error] The `reasoning_content` in the
|
||||
thinking mode must be passed back to the API.'}}
|
||||
```
|
||||
|
||||
Because the failure is a non-transient `400`, ADR-2032 D-8 correctly does **not**
|
||||
retry it; the actor-graph execution aborts and the error recurs on every
|
||||
attempt.
|
||||
|
||||
This ADR proposes routing reasoning / "thinking" models to a **reasoning-aware**
|
||||
provider client so that `reasoning_content` is preserved on the response leg and
|
||||
re-sent on the request leg, closing the round-trip.
|
||||
|
||||
### Why stock `ChatDeepSeek` is necessary but not sufficient
|
||||
|
||||
`langchain_deepseek.ChatDeepSeek` (a subclass of `BaseChatOpenAI`) solves **half**
|
||||
of the problem:
|
||||
|
||||
- On the **response leg** it overrides `_create_chat_result` and
|
||||
`_convert_chunk_to_generation_chunk` to copy the provider's
|
||||
`reasoning_content` (or OpenRouter's `reasoning`) into
|
||||
`AIMessage.additional_kwargs["reasoning_content"]`, for both non-streaming and
|
||||
streaming responses.
|
||||
- It accepts a custom endpoint via its `base_url` (aliased to `api_base`)
|
||||
constructor argument, so it can be pointed at any OpenAI-compatible reasoning
|
||||
proxy.
|
||||
|
||||
However, `ChatDeepSeek` inherits `_get_request_payload` /
|
||||
`_convert_message_to_dict` from `BaseChatOpenAI` unchanged. Its own
|
||||
`_get_request_payload` override only reshapes assistant/tool `content`; it never
|
||||
re-emits `reasoning_content`. Consequently, on the **request leg** the captured
|
||||
reasoning is still dropped, and the `400 … must be passed back` error persists.
|
||||
|
||||
Closing the round-trip therefore requires a thin subclass that additionally
|
||||
re-injects `reasoning_content` into the assistant message on the request leg.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
### D-1: New LLM Agent Configuration Field (`reasoning`)
|
||||
|
||||
**What:** One new optional configuration field is added to the LLM agent schema
|
||||
in §4.4.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `reasoning` | boolean | No | `false` | When `true`, and the agent's `provider` is a **non-native** provider (any provider outside `{openai, anthropic, google}`), the agent is constructed with the reasoning-aware provider client instead of the bare `ChatOpenAI(base_url=…)` client. This preserves and round-trips the provider's `reasoning_content`. Has no effect for native providers. |
|
||||
|
||||
**Spec relationship:** §4.4 defines the LLM agent configuration fields. This new
|
||||
field extends the field set without removing or altering any existing mandated
|
||||
parameter. Per §1.3, compliant implementations MAY accept additional agent
|
||||
configuration fields. When `reasoning` is absent or `false`, behaviour is
|
||||
byte-for-byte identical to ADR-2028 routing.
|
||||
|
||||
**Validation:** `reasoning`, when present, MUST be a boolean. A non-boolean value
|
||||
MUST be rejected with a `ConfigurationError` before the chat model is
|
||||
constructed (fail-fast argument validation).
|
||||
|
||||
### D-2: Reasoning-Aware Client Selection
|
||||
|
||||
**What:** `build_chat_model` gains an internal routing decision for the
|
||||
**credential-injection**, **non-native** path (the `_build_from_credentials`
|
||||
`else` branch of ADR-2028):
|
||||
|
||||
- `reasoning == false` (default) → construct `ChatOpenAI(base_url=…)`
|
||||
(unchanged ADR-2028 behaviour).
|
||||
- `reasoning == true` → construct the reasoning-aware client
|
||||
(`ReasoningChatModel`, see D-3) with the identical constructor keyword
|
||||
arguments (`base_url`, `api_key`, `model`, `temperature`, `max_tokens`).
|
||||
|
||||
The selection is expressed as a small provider-client **Strategy**: the two
|
||||
clients are interchangeable (both are `BaseChatModel` and both accept the same
|
||||
keyword arguments), so the surrounding loop, retry, pruning, and tool-dispatch
|
||||
logic is unaware of which one was selected (Liskov substitutability).
|
||||
|
||||
**Spec relationship:** Implementation detail of §4.4.1 provider routing. Refines
|
||||
ADR-2028's "all non-native providers → `ChatOpenAI`" into "non-native providers
|
||||
→ `ChatOpenAI` **unless** reasoning-aware routing is requested, in which case →
|
||||
reasoning-aware client".
|
||||
|
||||
### D-3: Reasoning-Aware Client (`ReasoningChatModel`)
|
||||
|
||||
**What:** A new provider-specific client class,
|
||||
`cleveractors.agents.llm_reasoning.ReasoningChatModel`, subclasses
|
||||
`langchain_deepseek.ChatDeepSeek`. It inherits the response-leg extraction of
|
||||
`reasoning_content` (D-4) and adds the request-leg re-emission (D-5).
|
||||
|
||||
Subclassing `ChatDeepSeek` (rather than `BaseChatOpenAI` or `ChatOpenAI`
|
||||
directly) reuses the upstream, tested response-leg extraction for both
|
||||
streaming and non-streaming responses, plus its OpenRouter `reasoning`
|
||||
compatibility, without re-implementing it.
|
||||
|
||||
**Spec relationship:** Implementation detail. Not visible at the Actor
|
||||
configuration boundary beyond the `reasoning` flag of D-1.
|
||||
|
||||
### D-4: Response-Leg Preservation
|
||||
|
||||
**What:** When the provider returns a `reasoning_content` field on an assistant
|
||||
message, the resulting `AIMessage` MUST carry it under
|
||||
`additional_kwargs["reasoning_content"]`. This behaviour is inherited unchanged
|
||||
from `ChatDeepSeek._create_chat_result` (non-streaming) and
|
||||
`_convert_chunk_to_generation_chunk` (streaming).
|
||||
|
||||
**Spec relationship:** The specification (§1.2) explicitly excludes "the internal
|
||||
mechanics of LLM providers" from its scope. `reasoning_content` is such an
|
||||
internal mechanic; preserving it is an implementation-quality concern that does
|
||||
not alter observable configuration-level behaviour for non-reasoning agents.
|
||||
|
||||
### D-5: Request-Leg Round-Trip
|
||||
|
||||
**What:** `ReasoningChatModel` overrides `_get_request_payload` to re-inject the
|
||||
preserved reasoning back onto the wire. After delegating to the superclass, for
|
||||
every serialised **assistant** message whose source `AIMessage` carried
|
||||
`additional_kwargs["reasoning_content"]`, the client MUST set the
|
||||
`reasoning_content` key on the corresponding request-payload message dict.
|
||||
|
||||
This is the delta over stock `ChatDeepSeek` and the decision that actually
|
||||
resolves the `400 … must be passed back` error: the assistant turn that precedes
|
||||
a tool result is replayed **with** its `reasoning_content` intact.
|
||||
|
||||
The re-injection MUST be a no-op when the assistant message carries no
|
||||
reasoning (absent, `None`, or empty), so that ordinary non-reasoning turns
|
||||
produce payloads identical to those `ChatDeepSeek` would produce.
|
||||
|
||||
**Spec relationship:** Implementation detail of the provider transport, outside
|
||||
the specification's scope (§1.2, item 3).
|
||||
|
||||
### D-6: Tool-Loop Replay Is Preserved Without Modification
|
||||
|
||||
**What:** `LLMAgent._execute_tool_loop` already appends the returned `AIMessage`
|
||||
object verbatim (`messages.append(response)`) before re-invoking. Because the
|
||||
`AIMessage` object — including its `additional_kwargs` — is stored by reference
|
||||
and never rebuilt, the `reasoning_content` captured under D-4 survives the
|
||||
in-memory replay automatically. **No change to `_execute_tool_loop` is required.**
|
||||
The round-trip fix is confined to the client's serialisation boundary (D-5).
|
||||
|
||||
This decision is verified by a Behave scenario (D-8) using a fake reasoning model
|
||||
that asserts the assistant message replayed after a tool call still carries the
|
||||
`reasoning_content` returned by the model.
|
||||
|
||||
**Spec relationship:** No spec change; confirmation that the existing §4.4 /
|
||||
tool-loop behaviour is compatible with reasoning round-tripping.
|
||||
|
||||
### D-7: New Dependency (`langchain-deepseek`)
|
||||
|
||||
**What:** `langchain-deepseek` (providing `ChatDeepSeek`) is added to the project
|
||||
runtime dependencies in `pyproject.toml`. It is imported through the existing
|
||||
lazy-import machinery
|
||||
(`cleveractors.agents.llm_imports.populate_langchain_globals` /
|
||||
`resolve_class_ref`) so that its absence degrades gracefully (a
|
||||
`ConfigurationError` naming the missing package) rather than raising
|
||||
`ImportError` at module import time — mirroring how `langchain-google-genai` is
|
||||
handled for the `google` provider.
|
||||
|
||||
**Spec relationship:** Packaging/operational concern, explicitly outside the
|
||||
specification's scope (§1.2, item 5).
|
||||
|
||||
### D-8: Non-Regression Guarantee
|
||||
|
||||
**What:** The change MUST NOT alter behaviour for:
|
||||
|
||||
1. **Native providers** (`openai`, `anthropic`, `google`) — routed by
|
||||
`_build_native`, entirely untouched by this ADR.
|
||||
2. **Non-reasoning non-native providers** (`reasoning` absent or `false`) —
|
||||
routed to `ChatOpenAI(base_url=…)` exactly as under ADR-2028.
|
||||
|
||||
For both, the request payloads produced MUST be byte-identical to those produced
|
||||
before this change. This is enforced by Behave scenarios that construct the
|
||||
default (non-reasoning) client and assert the serialised assistant payload
|
||||
contains no `reasoning_content` key, and that the reasoning client with a
|
||||
reasoning-free message produces the same payload.
|
||||
|
||||
**Spec relationship:** Backward-compatibility guarantee consistent with §1.3.
|
||||
|
||||
### D-9: SSRF and Base-URL Validation Are Unchanged
|
||||
|
||||
**What:** The reasoning-aware path reuses the ADR-2028 `_validate_base_url` and
|
||||
`_check_known_provider_domain` guards verbatim before constructing the client.
|
||||
All SSRF-prevention properties established by ADR-2028 (https-only, no userinfo,
|
||||
percent-decode loop, IP-literal and numeric-host rejection, `localhost`
|
||||
rejection) continue to hold for reasoning agents.
|
||||
|
||||
**Spec relationship:** Preserves the §13.4 network-boundary guarantees.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Reasoning models work end-to-end:** An `llm` agent pointed at a
|
||||
reasoning-capable OpenAI-compatible endpoint completes multi-turn tool-call
|
||||
loops without the `reasoning_content … must be passed back` `400` error.
|
||||
- **Opt-in and orthogonal:** A single boolean (`reasoning: true`) enables the
|
||||
behaviour for **any** non-native provider (`openai_compatible`, `openrouter`,
|
||||
`groq`, …). No per-provider proliferation of provider names.
|
||||
- **Zero regression by construction:** With `reasoning` absent/`false`, the
|
||||
routing, client class, and request payloads are unchanged from ADR-2028.
|
||||
Native providers are never touched.
|
||||
- **Reuses upstream code:** Response-leg extraction (streaming and
|
||||
non-streaming) and OpenRouter compatibility come from the maintained
|
||||
`ChatDeepSeek`; the project owns only the thin request-leg override.
|
||||
- **SOLID:** the two clients are interchangeable strategies (LSP); the reasoning
|
||||
concern is isolated in one subclass (SRP); routing is extended without editing
|
||||
the native path (OCP).
|
||||
|
||||
### Negative / Risks
|
||||
|
||||
- **New dependency:** `langchain-deepseek` (and its transitive `openai` pin) is
|
||||
added to the runtime. It is small and already aligned with the existing
|
||||
`langchain-openai` transitive stack. Guarded by lazy import so its absence is a
|
||||
clear configuration error, not an import crash.
|
||||
- **Provider-dependent semantics:** Some reasoning providers *require*
|
||||
`reasoning_content` to be echoed back (this issue), while others *reject* it on
|
||||
replay. The `reasoning` flag is therefore an operator decision keyed to the
|
||||
target endpoint's contract, not a universal default. This is documented in
|
||||
§4.4.
|
||||
- **Standalone/CLI mode unaffected:** Non-native providers remain unsupported in
|
||||
standalone mode (ADR-2028); reasoning-aware routing is available only on the
|
||||
credential-injection path. Extending standalone mode is out of scope.
|
||||
|
||||
### Follow-up Required
|
||||
|
||||
- **Behave scenarios** (`features/`) using a fake reasoning model in
|
||||
`features/mocks/` verifying: response-leg preservation, request-leg
|
||||
round-trip, tool-loop replay carrying `reasoning_content`, and the
|
||||
non-regression (no `reasoning_content` on the default path).
|
||||
- **Robot Framework integration test** (`robot/`) exercising the reasoning-aware
|
||||
routing path through the library API.
|
||||
- **Coverage** ≥ 97% via `nox -s coverage_report`; full `nox` green.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### A-1: Use stock `ChatDeepSeek` unchanged (no request-leg override)
|
||||
|
||||
**Rejected because:** `ChatDeepSeek` preserves `reasoning_content` on the
|
||||
response leg but inherits `BaseChatOpenAI._convert_message_to_dict` on the
|
||||
request leg, which never emits `reasoning_content`. The `400 … must be passed
|
||||
back` error is a **request-leg** failure, so stock `ChatDeepSeek` does not fix
|
||||
it. The thin `ReasoningChatModel` override of `_get_request_payload` (D-5) is the
|
||||
minimal addition that closes the round-trip.
|
||||
|
||||
### A-2: Introduce a new provider name `openai_compatible_reasoning`
|
||||
|
||||
**Rejected because:** It couples the reasoning concern to a single provider
|
||||
alias and does not compose with the named additional providers (`groq`,
|
||||
`openrouter`, …). A boolean `reasoning` flag is orthogonal to `provider`, applies
|
||||
uniformly to every non-native provider, and expresses exactly one concern (ISP /
|
||||
SRP). The alias approach was considered redundant with the flag.
|
||||
|
||||
### A-3: Monkeypatch or globally replace `langchain_openai._convert_message_to_dict`
|
||||
|
||||
**Rejected because:** Patching a third-party module's private serialiser at
|
||||
import time is fragile across `langchain-openai` versions and would silently
|
||||
affect **native** `openai` agents and the non-reasoning path, violating the
|
||||
byte-identical-payload guarantee (D-8). Confining the change to a subclass keeps
|
||||
the blast radius to reasoning agents only.
|
||||
|
||||
### A-4: Write a reasoning client from scratch on top of `ChatOpenAI`
|
||||
|
||||
**Rejected because:** It would re-implement the response-leg extraction that
|
||||
`ChatDeepSeek` already provides for both streaming and non-streaming paths, plus
|
||||
its OpenRouter compatibility — duplicating maintained upstream code and
|
||||
increasing the surface to test and keep current.
|
||||
|
||||
### A-5: Strip `reasoning_content` entirely before replay
|
||||
|
||||
**Rejected because:** For providers that require the reasoning block to be echoed
|
||||
back (this issue's provider), stripping it is precisely what produces the `400`
|
||||
error. This alternative is the status quo restated and does not satisfy the
|
||||
acceptance criteria.
|
||||
|
||||
### A-6: Enable reasoning-aware routing by default for all non-native providers
|
||||
|
||||
**Rejected because:** Some reasoning providers reject an echoed-back
|
||||
`reasoning_content`, and many non-native endpoints are not reasoning models at
|
||||
all. A default-on behaviour would risk regressions for existing `openai_compatible`
|
||||
deployments. Opt-in via `reasoning: true` (default `false`) preserves ADR-2028
|
||||
behaviour unless the operator explicitly requests the new path.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Reasoning-Aware LLM Agents
|
||||
|
||||
**Practical guide to the `reasoning` field on `type: llm` agents (spec [§4.4 / §4.4.1](../index.md#44-llm-agents-type-llm), [ADR-2036](../adr/ADR-2036-reasoning-aware-provider-routing.md)).**
|
||||
|
||||
---
|
||||
|
||||
## What `reasoning: true` does
|
||||
|
||||
In plain terms: this one field tells the agent to use a provider client that
|
||||
correctly remembers and re-sends the model's internal "thinking" output
|
||||
across a conversation, instead of silently throwing it away. As the person
|
||||
configuring the agent, you don't need to understand *how* it does that — you
|
||||
only need to know **when** to turn it on:
|
||||
|
||||
- Turn it on if your agent's `provider` is anything other than `openai`,
|
||||
`anthropic`, or `google` (i.e. it's an OpenAI-compatible / "non-native"
|
||||
provider, most commonly `openai_compatible`), **and** the model behind it
|
||||
is a reasoning / "thinking" model, **and** the agent also calls tools.
|
||||
Without it, a conversation that reaches a second round after a tool call
|
||||
will fail outright — see the error below.
|
||||
- Leave it off (the default) for everything else: `openai`, `anthropic`, and
|
||||
`google` agents ignore this field entirely, and non-reasoning models on any
|
||||
provider don't need it either. There is no downside to leaving it off when
|
||||
you don't need it — it's opt-in and changes nothing unless explicitly set.
|
||||
- It's a single boolean. You don't need to change how you declare `tools`,
|
||||
`tool_max_rounds`, `system_prompt`, or anything else about the agent — you
|
||||
just add `reasoning: true` alongside the fields you already have.
|
||||
|
||||
## Overview
|
||||
|
||||
Some LLM providers reached through the `openai_compatible` client (or another
|
||||
non-native provider) expose a reasoning / "thinking" model. These models
|
||||
return a `reasoning_content` field alongside the answer, and some of them
|
||||
*require* that field to be echoed back on the assistant turn that precedes a
|
||||
tool result — otherwise the follow-up call fails with:
|
||||
|
||||
```
|
||||
invalid_request_error: The `reasoning_content` in the thinking mode must be
|
||||
passed back to the API.
|
||||
```
|
||||
|
||||
Setting `reasoning: true` on such an agent constructs
|
||||
`cleveractors.agents.llm_reasoning.ReasoningChatModel` instead of the bare
|
||||
`ChatOpenAI` client, so `reasoning_content` round-trips correctly. Native
|
||||
providers (`openai`, `anthropic`, `google`) and agents that omit `reasoning`
|
||||
(or set it `false`) are unaffected — behavior there is byte-identical to
|
||||
before this feature existed.
|
||||
|
||||
## Enabling it
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
reasoning_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai_compatible
|
||||
model: deepseek-reasoner
|
||||
reasoning: true
|
||||
api_key: "${OPENAI_COMPATIBLE_API_KEY}"
|
||||
system_prompt: "You are a careful problem-solver."
|
||||
```
|
||||
|
||||
`reasoning` defaults to `false` and only has an effect when `provider` is
|
||||
anything other than `openai`, `anthropic`, or `google`. If the
|
||||
`langchain-deepseek` package is not installed, requesting `reasoning: true`
|
||||
fails fast with a `ConfigurationError` at agent construction time rather than
|
||||
degrading silently.
|
||||
|
||||
## Using it with tool calling
|
||||
|
||||
The scenario that motivated this feature is a reasoning model that also calls
|
||||
tools: the reasoning block produced on the turn immediately before a tool
|
||||
call must be replayed, unchanged, on the follow-up request that carries the
|
||||
tool's result.
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
research_assistant:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai_compatible
|
||||
model: deepseek-reasoner
|
||||
reasoning: true
|
||||
tools:
|
||||
- name: http_request
|
||||
tool_max_rounds: 5
|
||||
api_key: "${OPENAI_COMPATIBLE_API_KEY}"
|
||||
```
|
||||
|
||||
Enabling `reasoning` does not require any change to how tools are declared or
|
||||
how the multi-turn tool-call loop is configured — `tools` and
|
||||
`tool_max_rounds` behave exactly as they do for any other LLM agent.
|
||||
|
||||
## Normal (non-streaming) usage
|
||||
|
||||
```python
|
||||
from cleveractors.runtime import create_executor
|
||||
|
||||
executor = create_executor(
|
||||
config_dict={
|
||||
"type": "llm",
|
||||
"config": {
|
||||
"provider": "openai_compatible",
|
||||
"model": "deepseek-reasoner",
|
||||
"reasoning": True,
|
||||
"tools": [{"name": "http_request"}],
|
||||
},
|
||||
},
|
||||
credentials={"openai_compatible": {"api_key": "...", "base_url": "https://..."}},
|
||||
)
|
||||
|
||||
result = await executor.execute("What's the latest on the Mars sample-return mission?")
|
||||
print(result.response)
|
||||
```
|
||||
|
||||
`Executor.execute()` runs the agent's full multi-turn tool-call loop to
|
||||
completion via repeated `ainvoke()` calls and returns one `ActorResult`.
|
||||
Every round of that loop — including any follow-up call made after a tool
|
||||
result — carries the prior turn's `reasoning_content` back to the provider
|
||||
automatically.
|
||||
|
||||
## Streaming usage
|
||||
|
||||
```python
|
||||
async for token in executor.execute_stream("What's the latest on the Mars sample-return mission?"):
|
||||
print(token, end="", flush=True)
|
||||
|
||||
result = executor.last_result # populated once the async generator is exhausted
|
||||
```
|
||||
|
||||
`Executor.execute_stream()` behaves differently depending on whether the
|
||||
agent has tools configured:
|
||||
|
||||
- **No `tools` configured:** the model streams token-by-token via `astream()`,
|
||||
exactly as for a non-reasoning agent. `reasoning_content` returned by the
|
||||
provider is preserved on the final assembled message the same way it is on
|
||||
a non-streaming response — but since a single-turn stream never replays a
|
||||
prior assistant turn, there is nothing to re-inject on the request leg.
|
||||
- **`tools` configured:** the streaming call runs the *same* multi-turn
|
||||
`ainvoke()`-based tool-call loop `execute()` uses internally, then yields
|
||||
the finished answer as a single chunk once the loop completes, rather than
|
||||
incrementally. `reasoning_content` round-trips exactly as it does under
|
||||
`execute()` — every request made during that internal loop still carries
|
||||
the prior turn's `reasoning_content` — but the caller only sees the
|
||||
completed answer, not individual tokens, because tool calling and true
|
||||
token-by-token delivery are mutually exclusive in the current
|
||||
implementation (see `LLMAgent.stream_message` and its `_execute_tool_loop`
|
||||
fallback for the tools path).
|
||||
|
||||
In short: **`reasoning` behaves identically whether you call `execute()` or
|
||||
`execute_stream()`.** What differs between the two — token-by-token delivery
|
||||
vs. a single final chunk when tools are involved — is a pre-existing property
|
||||
of streaming with tools in general, not something specific to `reasoning`.
|
||||
|
||||
## See also
|
||||
|
||||
- [Actor Configuration Standard §4.4 / §4.4.1](../index.md#44-llm-agents-type-llm) — the normative field definition.
|
||||
- [ADR-2036: Reasoning-Aware Provider Routing](../adr/ADR-2036-reasoning-aware-provider-routing.md) — full design record and alternatives considered, including its relationship to the non-native (`openai_compatible`) provider routing it extends.
|
||||
+20
-2
@@ -1,6 +1,6 @@
|
||||
# The Actor Configuration Standard
|
||||
|
||||
**Version:** 1.2.0
|
||||
**Version:** 1.3.0
|
||||
**Status:** Normative
|
||||
|
||||
---
|
||||
@@ -291,6 +291,7 @@ LLM agents process messages by invoking a large language model. The following co
|
||||
| `template` | string | No | (none) | Name of a prompt template (registered under `prompts`) used to format the user message. |
|
||||
| `template_vars` | mapping | No | `{}` | Additional variables passed to the named `template`. |
|
||||
| `response_format` | mapping | No | (none) | Provider-specific structured-output specification (e.g., JSON schema). (§4.4.3) |
|
||||
| `reasoning` | boolean | No | `false` | When `true` and `provider` is a non-native provider (any provider outside `openai`/`anthropic`/`google`), the agent is constructed with a reasoning-aware client that preserves and round-trips the provider's `reasoning_content`. Has no effect for native providers. (§4.4.1) |
|
||||
|
||||
**Example — minimal LLM agent:**
|
||||
|
||||
@@ -334,6 +335,20 @@ agents:
|
||||
system_prompt: "You are an analytical assistant."
|
||||
```
|
||||
|
||||
**Example — LLM agent with reasoning-aware routing enabled (§4.4.1):**
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
reasoning_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai_compatible
|
||||
model: deepseek-reasoner
|
||||
reasoning: true
|
||||
api_key: "${OPENAI_COMPATIBLE_API_KEY}"
|
||||
system_prompt: "You are a careful problem-solver."
|
||||
```
|
||||
|
||||
#### 4.4.1 Providers
|
||||
|
||||
The following providers MUST be supported. The provider name comparison is case-insensitive.
|
||||
@@ -346,6 +361,8 @@ The following providers MUST be supported. The provider name comparison is case-
|
||||
|
||||
Additional providers MAY be supported.
|
||||
|
||||
**Reasoning-aware routing.** Providers other than `openai`, `anthropic`, and `google` are *non-native* and are reached through an OpenAI-compatible client. Some non-native providers expose a reasoning / "thinking" model that returns a `reasoning_content` field and require that field to be echoed back on the assistant turn preceding a subsequent tool result. When an LLM agent sets `reasoning: true` (§4.4) on a non-native provider, a conforming implementation SHOULD construct a reasoning-aware client that preserves `reasoning_content` on the response and re-sends it on the request, so that multi-turn tool-call loops with such providers succeed, regardless of whether the implementation's invocation surface is single-response or streaming. When `reasoning` is absent or `false`, or when the provider is native, this routing has no effect and behavior is unchanged. This is an implementation extension consistent with §1.2 (the internal mechanics of LLM providers are outside the scope of this standard); see `docs/adr/ADR-2036-reasoning-aware-provider-routing.md`.
|
||||
|
||||
When an API key is not supplied (neither as an `api_key` field nor through a corresponding environment variable), a `ConfigurationError` MUST be signaled with a message identifying the missing variables.
|
||||
|
||||
When the requested provider is not available in the host, an `AgentCreationError` MUST be signaled with a clear message describing the unavailable provider.
|
||||
@@ -3965,7 +3982,7 @@ Compliant implementations SHOULD signal the following error categories with the
|
||||
|
||||
## 21. Versioning of This Standard
|
||||
|
||||
This document is **Version 1.2.0** of the Actor Configuration Standard. Future revisions:
|
||||
This document is **Version 1.3.0** of the Actor Configuration Standard. Future revisions:
|
||||
|
||||
- A **patch** version (1.0.x) corrects errors and clarifies semantics without changing required behavior.
|
||||
- A **minor** version (1.x.0) adds new optional features without breaking conformance of existing implementations.
|
||||
@@ -3980,3 +3997,4 @@ Conformance MUST be declared against a specific version of this standard. Implem
|
||||
| 1.0.0 | Initial version. |
|
||||
| 1.1.0 | §5.4: `content_not_contains` Honored By extended from `Stream router, conditional nodes, bridge router` to `All subsystems`, adding pure-graph edges. Minor revision — restores the symmetric contains/not-contains treatment `content_contains` already had, without breaking conformance of configurations that do not use `content_not_contains` on pure-graph edges. See `docs/adr/ADR-2034-content-not-contains-all-subsystems.md`. |
|
||||
| 1.2.0 | §4.5: added the optional `tools_max_timeout` tool-agent config field (default `120`s) bounding a per-invocation `timeout` override, and noted that a tool MAY accept a per-invocation `timeout` argument overriding the default `timeout`. Minor revision — additive; existing configs are unaffected. See `docs/adr/ADR-2030-tool-calling-spec-extensions.md` (D-9). |
|
||||
| 1.3.0 | §4.4 / §4.4.1: added the optional `reasoning` boolean field to the LLM agent configuration. When `true` on a non-native provider, the agent is constructed with a reasoning-aware client that preserves and round-trips the provider's `reasoning_content`. Minor revision — a new optional feature; configurations that omit `reasoning` (or set it `false`) retain byte-identical behavior. See `docs/adr/ADR-2036-reasoning-aware-provider-routing.md`. |
|
||||
|
||||
@@ -19,3 +19,9 @@ Feature: LLM Imports Coverage
|
||||
When I import or reimport llm_imports
|
||||
Then the _LANGCHAIN_AVAILABLE flag in llm_imports should be False
|
||||
And AIMessage in the target globals should be None
|
||||
|
||||
Scenario: populate_langchain_globals sets ReasoningChatModel to None when langchain-deepseek not installed
|
||||
Given I simulate langchain_deepseek not being importable
|
||||
When I import or reimport llm_imports
|
||||
Then the _REASONING_AVAILABLE flag in llm_imports should be False
|
||||
And ReasoningChatModel in the target globals should be None
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Fake reasoning chat model for ADR-2036 Behave scenarios.
|
||||
|
||||
Provides :class:`FakeReasoningChatModel`, a minimal async test double that
|
||||
mimics a reasoning / "thinking" provider: every assistant turn it returns
|
||||
carries a ``reasoning_content`` value in ``additional_kwargs`` (as a real
|
||||
reasoning-aware client would after extracting it from the response), and it
|
||||
records the message list it receives on each call so scenarios can assert that
|
||||
the reasoning block survives the tool-loop replay (ADR-2036 D-6).
|
||||
|
||||
Test doubles live under ``features/mocks/`` exclusively (CONTRIBUTING.md).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
class FakeReasoningChatModel:
|
||||
"""Async chat-model double that emits and records ``reasoning_content``.
|
||||
|
||||
On the first ``ainvoke`` it returns an assistant message with a single tool
|
||||
call plus a ``reasoning_content`` block; on the second (and later) calls it
|
||||
returns a plain final answer (also carrying reasoning). Each call appends a
|
||||
shallow copy of the received ``messages`` list to :attr:`received_messages`.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool the first assistant turn should call.
|
||||
reasoning_text: The reasoning block attached to each assistant turn.
|
||||
final_text: The content of the terminating (no-tool-call) answer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tool_name: str = "echo",
|
||||
reasoning_text: str = "let me reason about this step by step",
|
||||
final_text: str = "final answer",
|
||||
) -> None:
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
raise ValueError("tool_name must be a non-empty string")
|
||||
if not isinstance(reasoning_text, str) or not reasoning_text:
|
||||
raise ValueError("reasoning_text must be a non-empty string")
|
||||
if not isinstance(final_text, str) or not final_text:
|
||||
raise ValueError("final_text must be a non-empty string")
|
||||
self.tool_name = tool_name
|
||||
self.reasoning_text = reasoning_text
|
||||
self.final_text = final_text
|
||||
self.temperature = 0.7
|
||||
self.call_count = 0
|
||||
self.received_messages: list[list[Any]] = []
|
||||
|
||||
def _ai_message(
|
||||
self, content: str, tool_calls: list[dict[str, Any]] | None = None
|
||||
) -> AIMessage:
|
||||
"""Build an AIMessage carrying reasoning_content and token usage."""
|
||||
return AIMessage(
|
||||
content=content,
|
||||
additional_kwargs={"reasoning_content": self.reasoning_text},
|
||||
tool_calls=tool_calls or [],
|
||||
usage_metadata={
|
||||
"input_tokens": 12,
|
||||
"output_tokens": 8,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
)
|
||||
|
||||
async def ainvoke(self, messages: list[Any], **_kwargs: Any) -> AIMessage:
|
||||
"""Return a tool-calling assistant turn, then a final answer."""
|
||||
self.call_count += 1
|
||||
self.received_messages.append(list(messages))
|
||||
if self.call_count == 1:
|
||||
return self._ai_message(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_reasoning_1",
|
||||
"name": self.tool_name,
|
||||
"args": {"message": "tool input"},
|
||||
}
|
||||
],
|
||||
)
|
||||
return self._ai_message(content=self.final_text)
|
||||
@@ -0,0 +1,76 @@
|
||||
Feature: Reasoning-aware provider routing (ADR-2036)
|
||||
As a CleverActors integrator pointing an LLM agent at a reasoning model
|
||||
behind an OpenAI-compatible endpoint
|
||||
I want reasoning models routed to a reasoning-aware client
|
||||
So that the provider's reasoning_content is preserved and round-tripped
|
||||
and the multi-turn tool-call loop does not fail with a 400 error
|
||||
|
||||
# Acceptance criteria from issue #101 / ADR-2036
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Client selection (ADR-2036 D-1, D-2)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Non-native provider with reasoning enabled routes to the reasoning-aware client
|
||||
Given a reasoning-aware LLMAgent for provider "openai_compatible" with reasoning enabled
|
||||
When the chat model is constructed
|
||||
Then the created model should be a ReasoningChatModel instance
|
||||
And the reasoning model should have the correct base_url from credentials
|
||||
|
||||
Scenario: Named non-native provider with reasoning enabled routes to the reasoning-aware client
|
||||
Given a reasoning-aware LLMAgent for provider "openrouter" with reasoning enabled
|
||||
When the chat model is constructed
|
||||
Then the created model should be a ReasoningChatModel instance
|
||||
|
||||
Scenario: Non-native provider with reasoning disabled routes to the plain OpenAI-compatible client
|
||||
Given a reasoning-aware LLMAgent for provider "openai_compatible" with reasoning disabled
|
||||
When the chat model is constructed
|
||||
Then the created model should be a plain ChatOpenAI instance
|
||||
And the created model should not be a ReasoningChatModel instance
|
||||
|
||||
Scenario: Reasoning routing is ignored for native providers
|
||||
Given a reasoning-aware LLMAgent for provider "openai" with reasoning enabled and only an api_key
|
||||
When the chat model is constructed
|
||||
Then the created model should not be a ReasoningChatModel instance
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration validation (ADR-2036 D-1)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: A non-boolean reasoning value is rejected with a ConfigurationError
|
||||
Given a reasoning-aware LLMAgent for provider "openai_compatible" with a non-boolean reasoning value
|
||||
When the chat model is constructed expecting an error
|
||||
Then a reasoning ConfigurationError should contain "'reasoning' must be a boolean"
|
||||
|
||||
Scenario: Reasoning routing without the langchain-deepseek client reports a helpful error
|
||||
Given a reasoning-aware LLMAgent for provider "openai_compatible" with reasoning enabled
|
||||
And the reasoning-aware client class is unavailable
|
||||
When the chat model is constructed expecting an error
|
||||
Then a reasoning ConfigurationError should contain "requires the 'langchain-deepseek' package"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Request-leg round-trip (ADR-2036 D-5) and non-regression (D-8)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: The reasoning-aware client re-sends reasoning_content on the assistant turn
|
||||
Given a ReasoningChatModel constructed against a reasoning endpoint
|
||||
And a conversation whose assistant turn carries reasoning_content and a tool call
|
||||
When the request payload is built
|
||||
Then the serialised assistant message should include the reasoning_content
|
||||
And the serialised tool message should not include any reasoning_content
|
||||
|
||||
Scenario: The reasoning-aware client leaves non-reasoning assistant turns unchanged
|
||||
Given a ReasoningChatModel constructed against a reasoning endpoint
|
||||
And a conversation whose assistant turn carries no reasoning_content
|
||||
When the request payload is built
|
||||
Then the serialised assistant message should not include any reasoning_content
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool-loop replay (ADR-2036 D-6) via a fake reasoning model
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: The tool loop replays the assistant turn with its reasoning_content after a tool call
|
||||
Given an LLMAgent with a fake reasoning model that calls tool "echo" then answers
|
||||
When the reasoning tool loop runs to completion
|
||||
Then the loop final answer should be "final answer"
|
||||
And the assistant message replayed after the tool call should carry the reasoning_content
|
||||
@@ -70,10 +70,12 @@ def _reimport_with_blocked(
|
||||
state: dict[str, bool] = {
|
||||
"_LANGCHAIN_AVAILABLE": _mod._LANGCHAIN_AVAILABLE,
|
||||
"_GOOGLE_AVAILABLE": _mod._GOOGLE_AVAILABLE,
|
||||
"_REASONING_AVAILABLE": _mod._REASONING_AVAILABLE,
|
||||
"_ChatAnthropic_is_None": _mod._ChatAnthropic is None,
|
||||
"_LangChainException_is_None": _mod._LangChainException is None,
|
||||
"_AIMessage_is_None": _mod._AIMessage is None,
|
||||
"_ChatGoogleGenerativeAI_is_None": (_mod._ChatGoogleGenerativeAI is None),
|
||||
"_ReasoningChatModel_is_None": (_mod._ReasoningChatModel is None),
|
||||
}
|
||||
return state
|
||||
finally:
|
||||
@@ -101,6 +103,18 @@ def step_simulate_langchain_core_missing(context: Any) -> None:
|
||||
context._blocked = ("langchain_core", "langchain_openai", "langchain_anthropic")
|
||||
|
||||
|
||||
@given("I simulate langchain_deepseek not being importable")
|
||||
def step_simulate_langchain_deepseek_missing(context: Any) -> None:
|
||||
"""Prepare to simulate absence of langchain_deepseek (ADR-2036).
|
||||
|
||||
Also drops the cached ``cleveractors.agents.llm_reasoning`` module so
|
||||
the reimport re-triggers its ``from langchain_deepseek import
|
||||
ChatDeepSeek`` line against the blocked package, matching how a real
|
||||
missing dependency would fail.
|
||||
"""
|
||||
context._blocked = ("langchain_deepseek", "cleveractors.agents.llm_reasoning")
|
||||
|
||||
|
||||
@when("I import or reimport llm_imports")
|
||||
def step_import_or_reimport_llm_imports(context: Any) -> None:
|
||||
"""Re-import llm_imports with the blocked packages."""
|
||||
@@ -141,3 +155,21 @@ def step_assert_aimessage_none_in_target(context: Any) -> None:
|
||||
assert state["_AIMessage_is_None"] is True, (
|
||||
f"Expected _AIMessage to be None, got state={state}"
|
||||
)
|
||||
|
||||
|
||||
@then("the _REASONING_AVAILABLE flag in llm_imports should be False")
|
||||
def step_assert_reasoning_not_available(context: Any) -> None:
|
||||
"""Verify _REASONING_AVAILABLE is False after reimport without langchain_deepseek."""
|
||||
state = context._llm_imports_state
|
||||
assert state["_REASONING_AVAILABLE"] is False, (
|
||||
f"Expected _REASONING_AVAILABLE=False, got {state['_REASONING_AVAILABLE']!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("ReasoningChatModel in the target globals should be None")
|
||||
def step_assert_reasoning_chat_model_none_in_target(context: Any) -> None:
|
||||
"""Verify _ReasoningChatModel is None after reimport without langchain_deepseek."""
|
||||
state = context._llm_imports_state
|
||||
assert state["_ReasoningChatModel_is_None"] is True, (
|
||||
f"Expected _ReasoningChatModel to be None, got state={state}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Step definitions for reasoning-aware provider routing (ADR-2036).
|
||||
|
||||
Covers client selection (ReasoningChatModel vs ChatOpenAI), the ``reasoning``
|
||||
config validation, the request-leg ``reasoning_content`` round-trip, and the
|
||||
tool-loop replay of a reasoning assistant turn via a fake reasoning model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from behave import given, then, when
|
||||
from features.mocks.credential_helpers import make_template_renderer
|
||||
from features.mocks.reasoning_model import FakeReasoningChatModel
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
|
||||
from cleveractors.agents.llm import LLMAgent
|
||||
from cleveractors.agents.llm_reasoning import ReasoningChatModel
|
||||
from cleveractors.core.exceptions import ConfigurationError
|
||||
|
||||
_BASE_URL = "https://api.example.com/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given — agent construction for routing/validation scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_reasoning_agent(
|
||||
context: Any,
|
||||
provider: str,
|
||||
*,
|
||||
reasoning: Any,
|
||||
include_base_url: bool = True,
|
||||
) -> None:
|
||||
"""Construct an LLMAgent with a reasoning config and credentials slice."""
|
||||
config: dict[str, Any] = {
|
||||
"provider": provider,
|
||||
"model": "reasoning-model",
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
credentials: dict[str, str] = {"api_key": f"injected-{provider}-key"}
|
||||
if include_base_url:
|
||||
credentials["base_url"] = _BASE_URL
|
||||
context.expected_base_url = _BASE_URL
|
||||
context.agent = LLMAgent(
|
||||
name=f"test_{provider}_reasoning_agent",
|
||||
config=config,
|
||||
template_renderer=make_template_renderer(),
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
|
||||
@given('a reasoning-aware LLMAgent for provider "{provider}" with reasoning enabled')
|
||||
def step_reasoning_agent_enabled(context: Any, provider: str) -> None:
|
||||
_build_reasoning_agent(context, provider, reasoning=True)
|
||||
|
||||
|
||||
@given('a reasoning-aware LLMAgent for provider "{provider}" with reasoning disabled')
|
||||
def step_reasoning_agent_disabled(context: Any, provider: str) -> None:
|
||||
_build_reasoning_agent(context, provider, reasoning=False)
|
||||
|
||||
|
||||
@given(
|
||||
'a reasoning-aware LLMAgent for provider "{provider}" with reasoning enabled '
|
||||
"and only an api_key"
|
||||
)
|
||||
def step_reasoning_agent_native(context: Any, provider: str) -> None:
|
||||
_build_reasoning_agent(context, provider, reasoning=True, include_base_url=False)
|
||||
|
||||
|
||||
@given(
|
||||
'a reasoning-aware LLMAgent for provider "{provider}" with a non-boolean '
|
||||
"reasoning value"
|
||||
)
|
||||
def step_reasoning_agent_non_boolean(context: Any, provider: str) -> None:
|
||||
_build_reasoning_agent(context, provider, reasoning="yes")
|
||||
|
||||
|
||||
@given("the reasoning-aware client class is unavailable")
|
||||
def step_reasoning_client_unavailable(context: Any) -> None:
|
||||
"""Simulate a missing langchain-deepseek by resolving the class to None."""
|
||||
context._reasoning_unavailable = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — trigger lazy chat-model construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _construct_chat_model(context: Any) -> Any:
|
||||
"""Access the chat_model property under any scenario-requested patches."""
|
||||
if getattr(context, "_reasoning_unavailable", False):
|
||||
with patch(
|
||||
"cleveractors.agents.llm_client.resolve_class_ref",
|
||||
return_value=None,
|
||||
):
|
||||
return context.agent.chat_model
|
||||
return context.agent.chat_model
|
||||
|
||||
|
||||
@when("the chat model is constructed")
|
||||
def step_construct_chat_model(context: Any) -> None:
|
||||
context.created_model = _construct_chat_model(context)
|
||||
|
||||
|
||||
@when("the chat model is constructed expecting an error")
|
||||
def step_construct_chat_model_expecting_error(context: Any) -> None:
|
||||
context.reasoning_error = None
|
||||
try:
|
||||
_construct_chat_model(context)
|
||||
except ConfigurationError as exc:
|
||||
context.reasoning_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — client selection assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the created model should be a ReasoningChatModel instance")
|
||||
def step_model_is_reasoning(context: Any) -> None:
|
||||
model = context.agent._chat_model
|
||||
assert isinstance(model, ReasoningChatModel), (
|
||||
f"Expected ReasoningChatModel, got {type(model).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@then("the created model should not be a ReasoningChatModel instance")
|
||||
def step_model_not_reasoning(context: Any) -> None:
|
||||
model = context.agent._chat_model
|
||||
assert not isinstance(model, ReasoningChatModel), (
|
||||
f"Expected a non-reasoning model, got {type(model).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@then("the created model should be a plain ChatOpenAI instance")
|
||||
def step_model_is_plain_chatopenai(context: Any) -> None:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
model = context.agent._chat_model
|
||||
assert isinstance(model, ChatOpenAI), (
|
||||
f"Expected ChatOpenAI, got {type(model).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@then("the reasoning model should have the correct base_url from credentials")
|
||||
def step_reasoning_model_base_url(context: Any) -> None:
|
||||
model = context.agent._chat_model
|
||||
actual = str(getattr(model, "api_base", "") or "")
|
||||
assert actual.startswith(context.expected_base_url.rstrip("/")), (
|
||||
f"Expected base_url to start with {context.expected_base_url!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('a reasoning ConfigurationError should contain "{expected_text}"')
|
||||
def step_reasoning_error_contains(context: Any, expected_text: str) -> None:
|
||||
error = context.reasoning_error
|
||||
assert error is not None, "Expected a ConfigurationError but none was raised"
|
||||
assert expected_text in str(error), (
|
||||
f"Expected error to contain {expected_text!r}, got {str(error)!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request-leg round-trip (ADR-2036 D-5 / D-8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a ReasoningChatModel constructed against a reasoning endpoint")
|
||||
def step_construct_reasoning_model_direct(context: Any) -> None:
|
||||
context.reasoning_model = ReasoningChatModel(
|
||||
base_url=_BASE_URL,
|
||||
api_key="sk-test",
|
||||
model="reasoning-model",
|
||||
temperature=0.7,
|
||||
max_tokens=128,
|
||||
)
|
||||
|
||||
|
||||
@given("a conversation whose assistant turn carries reasoning_content and a tool call")
|
||||
def step_conversation_with_reasoning(context: Any) -> None:
|
||||
context.reasoning_messages = [
|
||||
SystemMessage(content="system"),
|
||||
HumanMessage(content="what is the weather?"),
|
||||
AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"reasoning_content": "thinking about the weather"},
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_1",
|
||||
"name": "get_weather",
|
||||
"args": {"city": "NYC"},
|
||||
}
|
||||
],
|
||||
),
|
||||
ToolMessage(content="sunny", tool_call_id="call_1"),
|
||||
]
|
||||
context.expected_reasoning = "thinking about the weather"
|
||||
|
||||
|
||||
@given("a conversation whose assistant turn carries no reasoning_content")
|
||||
def step_conversation_without_reasoning(context: Any) -> None:
|
||||
context.reasoning_messages = [
|
||||
HumanMessage(content="hello"),
|
||||
AIMessage(content="hi there"),
|
||||
]
|
||||
|
||||
|
||||
@when("the request payload is built")
|
||||
def step_build_request_payload(context: Any) -> None:
|
||||
context.request_payload = context.reasoning_model._get_request_payload(
|
||||
context.reasoning_messages
|
||||
)
|
||||
|
||||
|
||||
def _payload_messages_by_role(context: Any, role: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
message
|
||||
for message in context.request_payload["messages"]
|
||||
if isinstance(message, dict) and message.get("role") == role
|
||||
]
|
||||
|
||||
|
||||
@then("the serialised assistant message should include the reasoning_content")
|
||||
def step_assistant_has_reasoning(context: Any) -> None:
|
||||
assistants = _payload_messages_by_role(context, "assistant")
|
||||
assert assistants, "No assistant message found in the request payload"
|
||||
assert assistants[0].get("reasoning_content") == context.expected_reasoning, (
|
||||
f"Expected reasoning_content {context.expected_reasoning!r}, "
|
||||
f"got {assistants[0].get('reasoning_content')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the serialised tool message should not include any reasoning_content")
|
||||
def step_tool_has_no_reasoning(context: Any) -> None:
|
||||
tools = _payload_messages_by_role(context, "tool")
|
||||
assert tools, "No tool message found in the request payload"
|
||||
for tool_message in tools:
|
||||
assert "reasoning_content" not in tool_message, (
|
||||
"Tool message unexpectedly carried reasoning_content"
|
||||
)
|
||||
|
||||
|
||||
@then("the serialised assistant message should not include any reasoning_content")
|
||||
def step_assistant_has_no_reasoning(context: Any) -> None:
|
||||
assistants = _payload_messages_by_role(context, "assistant")
|
||||
assert assistants, "No assistant message found in the request payload"
|
||||
for assistant in assistants:
|
||||
assert "reasoning_content" not in assistant, (
|
||||
"Assistant message unexpectedly carried reasoning_content"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-loop replay (ADR-2036 D-6) via a fake reasoning model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'an LLMAgent with a fake reasoning model that calls tool "{tool_name}" then answers'
|
||||
)
|
||||
def step_agent_with_fake_reasoning_model(context: Any, tool_name: str) -> None:
|
||||
config: dict[str, Any] = {
|
||||
"provider": "openai_compatible",
|
||||
"model": "reasoning-model",
|
||||
"tools": [{"name": tool_name}],
|
||||
"tool_max_rounds": 5,
|
||||
}
|
||||
context.agent = LLMAgent(
|
||||
name="fake_reasoning_agent",
|
||||
config=config,
|
||||
template_renderer=make_template_renderer(),
|
||||
)
|
||||
context.fake_reasoning_model = FakeReasoningChatModel(tool_name=tool_name)
|
||||
context.agent.chat_model = context.fake_reasoning_model
|
||||
|
||||
|
||||
@when("the reasoning tool loop runs to completion")
|
||||
def step_run_reasoning_tool_loop(context: Any) -> None:
|
||||
messages: list[Any] = [
|
||||
SystemMessage(content="system"),
|
||||
HumanMessage(content="please use the tool"),
|
||||
]
|
||||
context.loop_result = asyncio.run(context.agent._execute_tool_loop(messages))
|
||||
|
||||
|
||||
@then('the loop final answer should be "{expected}"')
|
||||
def step_loop_final_answer(context: Any, expected: str) -> None:
|
||||
content = context.loop_result.final_response.content
|
||||
assert content == expected, f"Expected final answer {expected!r}, got {content!r}"
|
||||
|
||||
|
||||
@then(
|
||||
"the assistant message replayed after the tool call should carry the reasoning_content"
|
||||
)
|
||||
def step_replayed_assistant_carries_reasoning(context: Any) -> None:
|
||||
fake = context.fake_reasoning_model
|
||||
assert fake.call_count >= 2, (
|
||||
f"Expected at least 2 LLM calls (tool call + follow-up), got {fake.call_count}"
|
||||
)
|
||||
replayed_messages = fake.received_messages[1]
|
||||
ai_messages = [
|
||||
message
|
||||
for message in replayed_messages
|
||||
if isinstance(message, AIMessage)
|
||||
and message.additional_kwargs.get("reasoning_content")
|
||||
]
|
||||
assert ai_messages, (
|
||||
"The replayed follow-up call did not include an assistant message "
|
||||
"carrying reasoning_content"
|
||||
)
|
||||
assert ai_messages[0].additional_kwargs["reasoning_content"] == fake.reasoning_text
|
||||
@@ -21,6 +21,8 @@ nav:
|
||||
- Built-in Tools: tools/built-in-tools.md
|
||||
- Safe Mode & Security: tools/safe-mode.md
|
||||
- Timeouts: tools/timeouts.md
|
||||
- Guides:
|
||||
- Reasoning-Aware LLM Agents: guides/reasoning-aware-llm-agents.md
|
||||
- Development:
|
||||
- Quality Automation: development/quality-automation.md
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"langchain-anthropic>=0.2.0",
|
||||
"langchain-community>=0.2.14",
|
||||
"langchain-openai>=0.2.0",
|
||||
"langchain-deepseek>=1.1.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pydantic>=2.7.0",
|
||||
"pyyaml>=6.0.3",
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Library for reasoning-aware provider routing integration tests (ADR-2036).
|
||||
|
||||
Exercises the full pipeline (Executor -> LLMAgent -> ToolAgent) with a
|
||||
*real* ``ReasoningChatModel`` selected by the real provider-routing code
|
||||
(``build_chat_model``). Only the OpenAI network boundary
|
||||
(``async_client.with_raw_response.create``) is stubbed — matching every
|
||||
other ``*.robot`` suite in this project (``ToolCallingTestLib.py``,
|
||||
``TokenBudgetTestLib.py``, ``SkillLoadingTestLib.py``), all of which mock
|
||||
only the LLM API call itself so integration tests can run without a real
|
||||
LLM API key. The production request-leg serialisation
|
||||
(``ReasoningChatModel._get_request_payload``) and response-leg extraction
|
||||
(``ChatDeepSeek._create_chat_result``) both run for real — no real
|
||||
reasoning endpoint or API key is required.
|
||||
|
||||
This proves end-to-end that a reasoning model behind an OpenAI-compatible
|
||||
endpoint completes a tool-call -> tool-result -> follow-up round with the
|
||||
``reasoning_content`` preserved and echoed back (issue #101 acceptance
|
||||
criteria 1 and 2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from openai.types.chat import ChatCompletion
|
||||
|
||||
from cleveractors.agents.llm_client import build_chat_model as _real_build_chat_model
|
||||
from cleveractors.agents.llm_reasoning import ReasoningChatModel
|
||||
from cleveractors.result import ActorResult
|
||||
from cleveractors.runtime import Executor, create_executor
|
||||
|
||||
_BASE_URL = "https://api.example.com/v1"
|
||||
_REASONING_ROUND_1 = "reasoning trace produced before the tool call"
|
||||
_FINAL_ANSWER = "Final answer after reasoning tool use"
|
||||
|
||||
|
||||
class _StubRawResponses:
|
||||
"""Stub for ``async_client.with_raw_response`` capturing outgoing payloads.
|
||||
|
||||
Returns a canned OpenAI ``ChatCompletion`` — carrying ``reasoning_content``
|
||||
as a provider extra field — first with a tool call, then with a final
|
||||
answer, mimicking a reasoning provider across a tool-call round.
|
||||
"""
|
||||
|
||||
def __init__(self, captured: list[dict[str, Any]], tool_name: str) -> None:
|
||||
self._captured = captured
|
||||
self._tool_name = tool_name
|
||||
self._call_count = 0
|
||||
|
||||
async def create(self, **payload: Any) -> Any:
|
||||
self._captured.append(payload)
|
||||
self._call_count += 1
|
||||
if self._call_count == 1:
|
||||
data = {
|
||||
"id": "cmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "reasoning-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": _REASONING_ROUND_1,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_reasoning_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self._tool_name,
|
||||
"arguments": '{"message": "reasoned input"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 8,
|
||||
"total_tokens": 28,
|
||||
},
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
"id": "cmpl-2",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "reasoning-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": _FINAL_ANSWER,
|
||||
"reasoning_content": "reasoning trace for the final answer",
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 15,
|
||||
"completion_tokens": 6,
|
||||
"total_tokens": 21,
|
||||
},
|
||||
}
|
||||
completion = ChatCompletion.model_validate(data)
|
||||
return SimpleNamespace(parse=lambda: completion, headers={})
|
||||
|
||||
|
||||
class ReasoningRoutingTestLib:
|
||||
"""Robot keywords for reasoning-aware routing integration tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._executor: Executor | None = None
|
||||
self._last_result: ActorResult | None = None
|
||||
self._captured_payloads: list[dict[str, Any]] = []
|
||||
self._constructed_model: Any = None
|
||||
self._patches: list[Any] = []
|
||||
|
||||
def _teardown_patches(self) -> None:
|
||||
for patcher in self._patches:
|
||||
patcher.stop()
|
||||
self._patches.clear()
|
||||
|
||||
def create_executor_with_reasoning_tool_agent(
|
||||
self, tool_name: str = "echo"
|
||||
) -> None:
|
||||
"""Create an Executor whose reasoning-enabled LLM agent uses ``tool_name``.
|
||||
|
||||
The real ``build_chat_model`` runs (selecting a real
|
||||
``ReasoningChatModel``); only the constructed model's network client is
|
||||
replaced with a capturing stub.
|
||||
"""
|
||||
self._teardown_patches()
|
||||
self._captured_payloads = []
|
||||
self._constructed_model = None
|
||||
|
||||
def _wrapped_build(*args: Any, **kwargs: Any) -> Any:
|
||||
model = _real_build_chat_model(*args, **kwargs)
|
||||
if isinstance(model, ReasoningChatModel):
|
||||
model.async_client = SimpleNamespace(
|
||||
with_raw_response=_StubRawResponses(
|
||||
self._captured_payloads, tool_name
|
||||
)
|
||||
)
|
||||
self._constructed_model = model
|
||||
return model
|
||||
|
||||
patcher = patch(
|
||||
"cleveractors.agents.llm.build_chat_model",
|
||||
side_effect=_wrapped_build,
|
||||
)
|
||||
patcher.start()
|
||||
self._patches.append(patcher)
|
||||
|
||||
config = {
|
||||
"type": "llm",
|
||||
"name": "reasoning_tool_agent",
|
||||
"provider": "openai_compatible",
|
||||
"model": "reasoning-model",
|
||||
"config": {
|
||||
"reasoning": True,
|
||||
"tools": [{"name": tool_name}],
|
||||
},
|
||||
}
|
||||
|
||||
self._executor = create_executor(
|
||||
config_dict=config,
|
||||
credentials={
|
||||
"openai_compatible": {
|
||||
"api_key": "mock-reasoning-key",
|
||||
"base_url": _BASE_URL,
|
||||
}
|
||||
},
|
||||
limits={},
|
||||
pricing={},
|
||||
)
|
||||
|
||||
async def _execute_async(self, message: str) -> ActorResult:
|
||||
assert self._executor is not None
|
||||
return await self._executor.execute(message)
|
||||
|
||||
def execute_with_message(self, message: str) -> None:
|
||||
"""Execute the executor with *message* and store the result."""
|
||||
try:
|
||||
self._last_result = asyncio.run(self._execute_async(message))
|
||||
finally:
|
||||
self._teardown_patches()
|
||||
|
||||
def result_is_valid_actor_result(self) -> None:
|
||||
assert isinstance(self._last_result, ActorResult), (
|
||||
f"Expected ActorResult, got {type(self._last_result)}"
|
||||
)
|
||||
|
||||
def constructed_model_is_reasoning_client(self) -> None:
|
||||
assert isinstance(self._constructed_model, ReasoningChatModel), (
|
||||
f"Expected a ReasoningChatModel to be constructed by the routing "
|
||||
f"code, got {type(self._constructed_model)}"
|
||||
)
|
||||
|
||||
def result_response_contains(self, text: str) -> None:
|
||||
assert self._last_result is not None
|
||||
assert text in self._last_result.response, (
|
||||
f"Expected {text!r} in response, got: {self._last_result.response[:200]!r}"
|
||||
)
|
||||
|
||||
def multiple_reasoning_rounds_occurred(self) -> None:
|
||||
assert len(self._captured_payloads) >= 2, (
|
||||
f"Expected >= 2 network calls (tool call + follow-up), "
|
||||
f"got {len(self._captured_payloads)}"
|
||||
)
|
||||
|
||||
def follow_up_request_carried_reasoning_content(self) -> None:
|
||||
"""Assert the replayed assistant turn re-sent ``reasoning_content``."""
|
||||
assert len(self._captured_payloads) >= 2, (
|
||||
"The follow-up request after the tool call was never made"
|
||||
)
|
||||
follow_up = self._captured_payloads[1]
|
||||
assistant_messages = [
|
||||
message
|
||||
for message in follow_up.get("messages", [])
|
||||
if isinstance(message, dict) and message.get("role") == "assistant"
|
||||
]
|
||||
assert assistant_messages, "No assistant message in the follow-up request"
|
||||
assert any(
|
||||
message.get("reasoning_content") == _REASONING_ROUND_1
|
||||
for message in assistant_messages
|
||||
), (
|
||||
"The follow-up request did not echo back the reasoning_content "
|
||||
"from the assistant turn preceding the tool result"
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
*** Settings ***
|
||||
Documentation Reasoning-aware provider routing integration tests (ADR-2036).
|
||||
... Drives the full pipeline (Executor -> LLMAgent -> ToolAgent) with a
|
||||
... real ReasoningChatModel selected by the real routing code; only the
|
||||
... OpenAI network boundary is stubbed, so the production request-leg
|
||||
... serialisation and response-leg extraction both run for real. No real
|
||||
... reasoning endpoint or API key is required.
|
||||
Library ReasoningRoutingTestLib.py
|
||||
|
||||
*** Test Cases ***
|
||||
Reasoning Model Completes A Tool Call Round Without Dropping Reasoning
|
||||
[Documentation] A reasoning-enabled openai_compatible agent completes a
|
||||
... tool-call -> tool-result -> follow-up round through the reasoning-aware
|
||||
... routing path, and the assistant turn replayed after the tool call
|
||||
... re-sends its reasoning_content (issue #101 acceptance criteria 1 and 2).
|
||||
Create Executor With Reasoning Tool Agent tool_name=echo
|
||||
Execute With Message Use the echo tool to repeat my input
|
||||
Result Is Valid Actor Result
|
||||
Constructed Model Is Reasoning Client
|
||||
Multiple Reasoning Rounds Occurred
|
||||
Result Response Contains Final answer after reasoning tool use
|
||||
Follow Up Request Carried Reasoning Content
|
||||
@@ -76,6 +76,15 @@ def build_chat_model(
|
||||
"""
|
||||
provider_lower = provider.lower()
|
||||
|
||||
# Reasoning-aware routing opt-in (ADR-2036 D-1). Validated here, before any
|
||||
# client construction, so a malformed value fails fast with a clear message.
|
||||
raw_reasoning = config.get("reasoning", False)
|
||||
if not isinstance(raw_reasoning, bool):
|
||||
raise ConfigurationError(
|
||||
f"'reasoning' must be a boolean, got {type(raw_reasoning).__name__}"
|
||||
)
|
||||
reasoning = raw_reasoning
|
||||
|
||||
try:
|
||||
if credentials is not None:
|
||||
return _build_from_credentials(
|
||||
@@ -86,6 +95,7 @@ def build_chat_model(
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
chat_model_globals=chat_model_globals,
|
||||
reasoning=reasoning,
|
||||
)
|
||||
else:
|
||||
return _build_standalone(
|
||||
@@ -126,13 +136,15 @@ def _build_from_credentials(
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
chat_model_globals: dict[str, Any],
|
||||
reasoning: bool,
|
||||
) -> BaseChatModel:
|
||||
"""Build a chat model using the injected credentials dict (ADR-2026/2028).
|
||||
|
||||
``credentials`` is the provider-specific slice already extracted by the
|
||||
caller (e.g. ``{"api_key": "sk-...", "base_url": "https://..."}``).
|
||||
For non-native providers routes unconditionally to
|
||||
``ChatOpenAI(base_url=..., api_key=...)``.
|
||||
For non-native providers routes to ``ChatOpenAI(base_url=..., api_key=...)``
|
||||
by default, or to the reasoning-aware ``ReasoningChatModel`` when
|
||||
``reasoning`` is ``True`` (ADR-2036 D-2).
|
||||
"""
|
||||
api_key = credentials.get("api_key")
|
||||
# Defense-in-depth: isinstance guard is unreachable under normal
|
||||
@@ -182,8 +194,23 @@ def _build_from_credentials(
|
||||
# ADR-2028 §Design: check known-provider domain patterns
|
||||
_check_known_provider_domain(provider_lower, decoded_hostname)
|
||||
|
||||
ChatOpenAI = chat_model_globals["ChatOpenAI"]
|
||||
return ChatOpenAI(
|
||||
# ADR-2036 D-2: select the reasoning-aware client when requested,
|
||||
# otherwise the bare ChatOpenAI(base_url=...) client of ADR-2028. Both
|
||||
# accept the identical keyword arguments (Liskov substitutability), so
|
||||
# the surrounding tool-loop/retry/pruning logic is agnostic to which is
|
||||
# constructed.
|
||||
if reasoning:
|
||||
client_class = resolve_class_ref("ReasoningChatModel", chat_model_globals)
|
||||
if client_class is None:
|
||||
raise ConfigurationError(
|
||||
f"reasoning-aware routing for provider '{provider}' requires "
|
||||
f"the 'langchain-deepseek' package; install it to use "
|
||||
f"'reasoning: true'."
|
||||
)
|
||||
else:
|
||||
client_class = chat_model_globals["ChatOpenAI"]
|
||||
|
||||
return client_class(
|
||||
base_url=validated_base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
|
||||
@@ -52,6 +52,21 @@ except ImportError:
|
||||
_ChatGoogleGenerativeAI = None
|
||||
_GOOGLE_AVAILABLE = False
|
||||
|
||||
# Reasoning-aware client for non-native reasoning providers (ADR-2036). Guarded
|
||||
# like the google provider above so that a missing langchain-deepseek degrades
|
||||
# to a clear ConfigurationError (via resolve_class_ref) rather than an
|
||||
# ImportError at module load. Importing ReasoningChatModel also pulls in its
|
||||
# ChatDeepSeek base class, so a single guard covers the whole dependency.
|
||||
try:
|
||||
from cleveractors.agents.llm_reasoning import (
|
||||
ReasoningChatModel as _ReasoningChatModel,
|
||||
)
|
||||
|
||||
_REASONING_AVAILABLE = True
|
||||
except ImportError:
|
||||
_ReasoningChatModel = None
|
||||
_REASONING_AVAILABLE = False
|
||||
|
||||
|
||||
# Module-level lock for populate_langchain_globals(). The section guarded by this
|
||||
# lock performs only synchronous assignment — it must remain await-free.
|
||||
@@ -90,6 +105,11 @@ def populate_langchain_globals(target_globals: dict[str, Any]) -> None:
|
||||
else:
|
||||
target_globals["ChatGoogleGenerativeAI"] = None
|
||||
|
||||
if _REASONING_AVAILABLE:
|
||||
target_globals["ReasoningChatModel"] = _ReasoningChatModel
|
||||
else:
|
||||
target_globals["ReasoningChatModel"] = None
|
||||
|
||||
target_globals["LANGCHAIN_IMPORTS_DONE"] = True
|
||||
|
||||
|
||||
@@ -107,7 +127,14 @@ def resolve_class_ref(class_ref: str, target_globals: dict[str, Any]) -> Any:
|
||||
Raises:
|
||||
ConfigurationError: If the *name* is unknown (not in the mapping).
|
||||
"""
|
||||
known_refs = frozenset({"ChatOpenAI", "ChatAnthropic", "ChatGoogleGenerativeAI"})
|
||||
known_refs = frozenset(
|
||||
{
|
||||
"ChatOpenAI",
|
||||
"ChatAnthropic",
|
||||
"ChatGoogleGenerativeAI",
|
||||
"ReasoningChatModel",
|
||||
}
|
||||
)
|
||||
if class_ref not in known_refs:
|
||||
raise ConfigurationError(
|
||||
f"Unknown class reference: {class_ref!r}. "
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Reasoning-aware chat model for non-native reasoning providers (ADR-2036).
|
||||
|
||||
This module defines :class:`ReasoningChatModel`, a thin subclass of
|
||||
``langchain_deepseek.ChatDeepSeek`` used to route reasoning / "thinking"
|
||||
models behind OpenAI-compatible endpoints (ADR-2028 non-native providers)
|
||||
so that the provider's ``reasoning_content`` round-trips correctly.
|
||||
|
||||
``ChatDeepSeek`` already preserves ``reasoning_content`` on the **response**
|
||||
leg (copying it into ``AIMessage.additional_kwargs["reasoning_content"]`` for
|
||||
both streaming and non-streaming responses). It does **not**, however,
|
||||
re-emit that value on the **request** leg — it inherits
|
||||
``BaseChatOpenAI._convert_message_to_dict``, which serialises assistant
|
||||
messages as ``role``/``content``/``tool_calls`` only. Providers that require
|
||||
the reasoning block to be echoed back on the assistant turn preceding a tool
|
||||
result therefore reject the follow-up request with::
|
||||
|
||||
invalid_request_error: The `reasoning_content` in the thinking mode must
|
||||
be passed back to the API.
|
||||
|
||||
:class:`ReasoningChatModel` closes that gap (ADR-2036 D-5): after delegating to
|
||||
the superclass payload construction it re-injects ``reasoning_content`` onto
|
||||
each serialised assistant message whose source ``AIMessage`` carried it. When
|
||||
a message carries no reasoning the injection is a no-op, so ordinary
|
||||
non-reasoning turns produce payloads identical to those ``ChatDeepSeek`` would
|
||||
produce (ADR-2036 D-8).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage, BaseMessage
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
# Key under which the reasoning block is stored on an ``AIMessage`` (set by
|
||||
# ``ChatDeepSeek`` on the response leg) and re-emitted on the request leg.
|
||||
_REASONING_CONTENT_KEY = "reasoning_content"
|
||||
|
||||
# Role of a serialised assistant message in the chat/completions payload.
|
||||
_ASSISTANT_ROLE = "assistant"
|
||||
|
||||
|
||||
class ReasoningChatModel(ChatDeepSeek):
|
||||
"""Reasoning-aware OpenAI-compatible chat model (ADR-2036).
|
||||
|
||||
Behaves exactly like :class:`langchain_deepseek.ChatDeepSeek` except that
|
||||
it re-sends the ``reasoning_content`` captured from the model on the
|
||||
assistant turn(s) of the next request, so that reasoning providers which
|
||||
mandate the round-trip accept the follow-up call after a tool result.
|
||||
|
||||
Only the request-leg serialisation is customised; response parsing,
|
||||
streaming, tool binding, and structured output are inherited unchanged.
|
||||
"""
|
||||
|
||||
def _get_request_payload(
|
||||
self,
|
||||
input_: LanguageModelInput,
|
||||
*,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the request payload, re-emitting ``reasoning_content``.
|
||||
|
||||
Delegates to the superclass to construct the base payload, then walks
|
||||
the serialised assistant messages and re-injects the
|
||||
``reasoning_content`` carried by their source :class:`AIMessage`
|
||||
instances (ADR-2036 D-5).
|
||||
|
||||
In the chat/completions path the superclass emits a positional
|
||||
``messages`` list that is 1:1 with the source messages, so pairing them
|
||||
with :func:`zip` aligns each serialised message to its origin. When a
|
||||
source assistant message carries no reasoning the corresponding payload
|
||||
message is left untouched, keeping non-reasoning turns byte-identical to
|
||||
the superclass output (ADR-2036 D-8).
|
||||
|
||||
Args:
|
||||
input_: The chat model input (messages or a prompt value).
|
||||
stop: Optional stop sequences forwarded to the superclass.
|
||||
**kwargs: Additional keyword arguments forwarded to the superclass.
|
||||
|
||||
Returns:
|
||||
The request payload dict, with ``reasoning_content`` re-attached to
|
||||
any assistant message whose source carried it.
|
||||
"""
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
|
||||
payload_messages = payload.get("messages")
|
||||
source_messages: list[BaseMessage] = self._convert_input(input_).to_messages()
|
||||
|
||||
# ``payload_messages or []`` yields an empty iterable — and hence a
|
||||
# no-op loop — when the superclass produced no positional ``messages``
|
||||
# list (e.g. a future responses-API path), so no defensive early return
|
||||
# is required. ``strict=False`` because that fallback intentionally
|
||||
# makes the two sequences unequal length.
|
||||
for source_message, payload_message in zip(
|
||||
source_messages, payload_messages or [], strict=False
|
||||
):
|
||||
reasoning = source_message.additional_kwargs.get(_REASONING_CONTENT_KEY)
|
||||
if (
|
||||
isinstance(source_message, AIMessage)
|
||||
and isinstance(payload_message, dict)
|
||||
and payload_message.get("role") == _ASSISTANT_ROLE
|
||||
and isinstance(reasoning, str)
|
||||
and reasoning
|
||||
):
|
||||
payload_message[_REASONING_CONTENT_KEY] = reasoning
|
||||
|
||||
return payload
|
||||
Reference in New Issue
Block a user