feat(llm): route reasoning models to reasoning-aware provider clients #106

Merged
CoreRasurae merged 1 commits from feature/m2-reasoning-model-provider-support into master 2026-08-05 22:19:58 +00:00
16 changed files with 1468 additions and 7 deletions
+8
View File
@@ -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.
+158
View File
@@ -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
View File
@@ -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`. |
+6
View File
@@ -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
+84
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -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",
+237
View File
@@ -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"
)
+22
View File
@@ -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
+31 -4
View File
@@ -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,
+28 -1
View File
@@ -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}. "
+109
View File
@@ -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