diff --git a/pyproject.toml b/pyproject.toml
index e9ccc83ca..e2eddfb7c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,6 +59,7 @@ openenv = [
trl = [
"agentenv-framework[openenv]",
"datasets>=3",
+ "transformers>=5.2", # renders the tools as TRL shows them to a model; 5.2 is what TRL's environment_factory needs
]
# The AWS store backends (S3, Secrets Manager, DynamoDB, ECR), selected by impl pointer in config.toml.
aws = [
@@ -106,7 +107,6 @@ boat = [
dev = [
"agentenv-framework[aws,boat,boxd,daytona,gcp,openenv,sail,tensorlake,tenki,trl,vercel]", # exercise portable optional providers and stores in CI
"fastmcp>=3.4,<4", # the standalone-fastmcp mount path of the protocol; <4 keeps mcp 1.x
- "transformers>=5.2", # get_json_schema, which reads agent_env.training's tool methods as TRL shows them to a model
"Pillow>=10.4", # the protocol's computer extra, whose tests the unit tier runs
"av>=17", # the protocol's computer extra, whose tests the unit tier runs and which the recording tests decode with
"griffelib==2.3.0", # the plugin API check; exact, since what it reports as a break changes between releases
diff --git a/src/agent_env/training/_tools.py b/src/agent_env/training/_tools.py
index 6df1359e4..3eca70dae 100644
--- a/src/agent_env/training/_tools.py
+++ b/src/agent_env/training/_tools.py
@@ -1,6 +1,6 @@
"""Python methods for an env's tools, typed and documented from each tool's JSON Schema the way transformers'
-``get_json_schema`` reads a function, which is how TRL shows a model an environment's tools. Built without generating
-source; a method calls its instance's ``_call(name, arguments)``."""
+``get_json_schema`` reads a function, which is how TRL finds an environment's tools, and the ``SchemaTool`` each one
+reaches the model as. Built without generating source; a method calls its instance's ``_call(name, arguments)``."""
from __future__ import annotations
@@ -47,13 +47,32 @@ def method(self: Any, **arguments: Any) -> str:
return self._call(name, arguments)
method.__name__ = method.__qualname__ = name
- method.__doc__ = (tool.get("description") or f"Call the {name} tool.") + (
+ method.__doc__ = _summary(tool) + (
"\n\nArgs:\n" + "\n".join(described) if described else "")
method.__annotations__ = annotations
method.__signature__ = inspect.Signature(parameters, return_annotation=str)
return method
+class SchemaTool(dict):
+ """A tool as both its env's JSON Schema, which transformers renders as given, and the callable TRL calls: a method's
+ type hints have no nested types. TRL lists the methods on the class and takes the tools from the instance, which
+ sets one over each; a tool has no docstring, so ``get_json_schema`` refuses it rather than make up a schema."""
+
+ def __init__(self, tool: Mapping[str, Any], call: Callable[..., str]):
+ super().__init__(type="function", function={
+ "name": tool["name"], "description": _summary(tool), "parameters": tool["input_schema"]})
+ self.__name__, self.__doc__ = tool["name"], None
+ self._function = call
+
+ def __call__(self, **arguments: Any) -> str:
+ return self._function(**arguments)
+
+
+def _summary(tool: Mapping[str, Any]) -> str:
+ return tool.get("description") or f"Call the {tool['name']} tool."
+
+
def _callable_name(name: str) -> bool:
return name.isidentifier() and not keyword.iskeyword(name) and not name.startswith("_")
diff --git a/src/agent_env/training/trl.py b/src/agent_env/training/trl.py
index 21710f739..d84747ce5 100644
--- a/src/agent_env/training/trl.py
+++ b/src/agent_env/training/trl.py
@@ -1,7 +1,7 @@
"""TRL's trainers on a bundle served as an OpenEnv env (``agent_env.openenv.build_app``, or the package ``openenv import``
writes): ``AgentEnv`` reads the server's tasks into a dataset, and makes the ``environment_factory`` whose environments
each play one episode of a task over OpenEnv's WebSocket, offer its turn's tools as methods and own its reward.
-``GRPOTrainer`` takes both as they are, verified with TRL 1.16, and AsyncGRPO takes the same ``environment_factory``;
+``GRPOTrainer`` takes both as they are, verified with TRL 1.16 and 1.17, and AsyncGRPO the same ``environment_factory``;
either needs TRL 1.15 or later, for an environment that owns its reward. Every session runs on one event-loop thread of
this module's own, since TRL calls an environment synchronously, from its own threads."""
@@ -29,8 +29,9 @@
try:
from datasets import Dataset
from openenv.core.generic_client import GenericEnvClient
+ from transformers.utils.chat_template_utils import render_jinja_template
except ModuleNotFoundError as exc:
- if exc.name not in ("datasets", "openenv"):
+ if exc.name not in ("datasets", "openenv", "transformers"):
raise
raise ImportError("agent_env.training.trl requires pip install 'agentenv-framework[trl]'") from exc
@@ -38,7 +39,7 @@
from agent_env.openenv.environment import END_TURN as END_TURN_TOOL
from agent_env.openenv.environment import TIMEOUT_METADATA, Toolset
from agent_env.task.teardown import TERMINATE_TIMEOUT_SECONDS
-from agent_env.training._tools import tool_method
+from agent_env.training._tools import SchemaTool, tool_method
logger = logging.getLogger(__name__)
@@ -247,8 +248,9 @@ class _Rollout:
class _Environment:
"""One session on the server, playing one episode at a time. Its public methods other than ``reset`` and
- ``get_reward`` are the tools, which ``AgentEnv.factory`` binds per toolset; every call on the session waits its
- turn, as AsyncGRPO can call two tools of a completion at once."""
+ ``get_reward`` are the tools, which ``AgentEnv.factory`` binds per toolset, and each but ``end_turn`` is set over
+ with its ``SchemaTool``; every call on the session waits its turn, as AsyncGRPO can call two tools of a completion
+ at once."""
_shared: ClassVar[_Shared]
_tools: ClassVar[tuple[dict[str, Any], ...]]
@@ -258,6 +260,9 @@ def __init__(self) -> None:
self._opened = False
self._rollout: _Rollout | None = None
self._lock = asyncio.Lock()
+ for tool in self._tools:
+ if tool["name"] != END_TURN:
+ setattr(self, tool["name"], SchemaTool(tool, getattr(self, tool["name"])))
_environments.add(self)
def reset(self, *, split: str, index: int, task_id: str | None = None, **_: Any) -> None:
@@ -413,9 +418,19 @@ def _environment_class(name: str, tools: tuple[dict[str, Any], ...], shared: _Sh
methods = {tool["name"]: tool_method(tool) for tool in tools}
for method in methods.values():
method.__qualname__ = f"{name}.{method.__name__}"
+ _check_rendered([SchemaTool(tool, methods[tool["name"]]) for tool in tools if tool["name"] != END_TURN])
return _EnvironmentClass(name, (_Environment,), {**methods, "_shared": shared, "_tools": tools})
+def _check_rendered(tools: list[SchemaTool]) -> None:
+ """Fails as the trainer sets up, not as the model meets the tools, when transformers doesn't render a tool given
+ as its JSON Schema as it is."""
+ [rendered], _ = render_jinja_template([[]], tools=tools, chat_template="{{ tools | tojson }}")
+ if changed := [tool.__name__ for tool, shown in zip(tools, json.loads(rendered), strict=True) if shown != tool]:
+ raise RuntimeError(f"this transformers renders the tools {changed} other than as their env's JSON Schema, so "
+ "TRL would show the model other tools than the env's")
+
+
def _reduce_environment_class(environment: _EnvironmentClass) -> tuple[Any, ...] | str:
"""A class ``_environment_class`` built pickles as what it's built from, and a subclass of one by its name, as any
class does, so it keeps what it overrides."""
diff --git a/tst/data/training/qwen3_chat_template.jinja b/tst/data/training/qwen3_chat_template.jinja
new file mode 100644
index 000000000..01be9b307
--- /dev/null
+++ b/tst/data/training/qwen3_chat_template.jinja
@@ -0,0 +1,89 @@
+{%- if tools %}
+ {{- '<|im_start|>system\n' }}
+ {%- if messages[0].role == 'system' %}
+ {{- messages[0].content + '\n\n' }}
+ {%- endif %}
+ {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within XML tags:\n" }}
+ {%- for tool in tools %}
+ {{- "\n" }}
+ {{- tool | tojson }}
+ {%- endfor %}
+ {{- "\n\n\nFor each function call, return a json object with function name and arguments within XML tags:\n\n{\"name\": , \"arguments\": }\n<|im_end|>\n" }}
+{%- else %}
+ {%- if messages[0].role == 'system' %}
+ {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
+ {%- endif %}
+{%- endif %}
+{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
+{%- for message in messages[::-1] %}
+ {%- set index = (messages|length - 1) - loop.index0 %}
+ {%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('') and message.content.endswith('')) %}
+ {%- set ns.multi_step_tool = false %}
+ {%- set ns.last_query_index = index %}
+ {%- endif %}
+{%- endfor %}
+{%- for message in messages %}
+ {%- if message.content is string %}
+ {%- set content = message.content %}
+ {%- else %}
+ {%- set content = '' %}
+ {%- endif %}
+ {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
+ {%- elif message.role == "assistant" %}
+ {%- set reasoning_content = '' %}
+ {%- if message.reasoning_content is string %}
+ {%- set reasoning_content = message.reasoning_content %}
+ {%- else %}
+ {%- if '' in content %}
+ {%- set reasoning_content = content.split('')[0].rstrip('\n').split('')[-1].lstrip('\n') %}
+ {%- set content = content.split('')[-1].lstrip('\n') %}
+ {%- endif %}
+ {%- endif %}
+ {%- if loop.index0 > ns.last_query_index %}
+ {%- if loop.last or (not loop.last and reasoning_content) %}
+ {{- '<|im_start|>' + message.role + '\n\n' + reasoning_content.strip('\n') + '\n\n\n' + content.lstrip('\n') }}
+ {%- else %}
+ {{- '<|im_start|>' + message.role + '\n' + content }}
+ {%- endif %}
+ {%- else %}
+ {{- '<|im_start|>' + message.role + '\n' + content }}
+ {%- endif %}
+ {%- if message.tool_calls %}
+ {%- for tool_call in message.tool_calls %}
+ {%- if (loop.first and content) or (not loop.first) %}
+ {{- '\n' }}
+ {%- endif %}
+ {%- if tool_call.function %}
+ {%- set tool_call = tool_call.function %}
+ {%- endif %}
+ {{- '\n{"name": "' }}
+ {{- tool_call.name }}
+ {{- '", "arguments": ' }}
+ {%- if tool_call.arguments is string %}
+ {{- tool_call.arguments }}
+ {%- else %}
+ {{- tool_call.arguments | tojson }}
+ {%- endif %}
+ {{- '}\n' }}
+ {%- endfor %}
+ {%- endif %}
+ {{- '<|im_end|>\n' }}
+ {%- elif message.role == "tool" %}
+ {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
+ {{- '<|im_start|>user' }}
+ {%- endif %}
+ {{- '\n\n' }}
+ {{- content }}
+ {{- '\n' }}
+ {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
+ {{- '<|im_end|>\n' }}
+ {%- endif %}
+ {%- endif %}
+{%- endfor %}
+{%- if add_generation_prompt %}
+ {{- '<|im_start|>assistant\n' }}
+ {%- if enable_thinking is defined and enable_thinking is false %}
+ {{- '\n\n\n\n' }}
+ {%- endif %}
+{%- endif %}
\ No newline at end of file
diff --git a/tst/trl/grpo_test.py b/tst/trl/grpo_test.py
index b5c210f8d..3c9b9a12f 100644
--- a/tst/trl/grpo_test.py
+++ b/tst/trl/grpo_test.py
@@ -1,6 +1,7 @@
"""TRL's GRPOTrainer trains a step through ``agent_env.training.trl`` on CPU, against the OpenEnv server ``build_app``
makes over the Episode API's fake steps. TRL marks ``environment_factory`` experimental, so this pins what the helper
-relies on: TRL resets an environment with its row, calls its tools, reads ``get_reward``, and trains on the rewards.
+relies on: TRL shows the model each tool as its env publishes it, resets an environment with its row, calls its tools,
+reads ``get_reward``, and trains on the rewards.
As in TRL's own environment tests, a tiny model's ``generate`` is stood in for. CI runs it against TRL 1.16, the oldest
that trains on CPU, and the latest; AsyncGRPO needs a vLLM server, so it isn't covered here."""
@@ -8,7 +9,8 @@
import pytest
-from tst.unit.training.trl_test import PLAYED
+from tst.unit.training.tools_test import tools_in
+from tst.unit.training.trl_test import PLAYED, SHOWN, STACK
from tst.util.capabilities import TRL, missing_capability_reason
torch = pytest.importorskip("torch", reason=missing_capability_reason(TRL))
@@ -19,9 +21,10 @@
END = '\n{"name": "end_turn", "arguments": {"response": "Added a crate."}}\n'
-def test_grpotrainer_trains_a_step_on_a_served_bundle_and_scores_every_rollout(served, teardowns, tmp_path,
+def test_grpotrainer_trains_a_step_on_a_served_bundle_and_scores_every_rollout(served, teardowns, tools, tmp_path,
monkeypatch):
monkeypatch.setenv("TRL_EXPERIMENTAL_SILENCE", "1")
+ tools("add_item", STACK)
env = served({"a": PLAYED, "b": PLAYED})
rewards = []
@@ -36,10 +39,12 @@ def get_reward(self):
use_cpu=True))
before = {name: param.clone() for name, param in trainer.model.named_parameters()}
- with patch.object(trainer.model, "generate", side_effect=_Policy(trainer.processing_class)):
+ policy = _Policy(trainer.processing_class)
+ with patch.object(trainer.model, "generate", side_effect=policy):
trainer.train()
[logged] = [row for row in trainer.state.log_history if "tools/call_frequency" in row]
+ assert tools_in(policy.prompt) == SHOWN
assert sorted(rewards) == [0.0, 0.0, 1.0, 1.0]
assert (logged["tools/call_frequency"], logged["tools/failure_frequency"], logged["rewards/Recorded/mean"]) == (
pytest.approx(1.0), 0.0, pytest.approx(0.5))
@@ -50,14 +55,16 @@ def get_reward(self):
class _Policy:
"""Stands in for ``generate``: of each group, the first rollout adds a crate, then ends its turn, then stops, and the
- second answers without a tool, so its turn is ended empty and scores 0."""
+ second answers without a tool, so its turn is ended empty and scores 0. ``prompt`` is the first it was given."""
def __init__(self, tokenizer):
self.tokenizer = tokenizer
self.calls = 0
+ self.prompt = None
def __call__(self, input_ids, **_):
self.calls += 1
+ self.prompt = self.prompt or self.tokenizer.decode(input_ids[0])
if self.calls == 1:
texts = [ADD if row % 2 == 0 else "I won't." for row in range(len(input_ids))]
else:
diff --git a/tst/unit/openenv/environment_test.py b/tst/unit/openenv/environment_test.py
index b8d9c5d29..046800be3 100644
--- a/tst/unit/openenv/environment_test.py
+++ b/tst/unit/openenv/environment_test.py
@@ -21,9 +21,9 @@
class _Tools:
- """Stands in for the proxy's ``ToolSession``: every env offers ``offered``, and a call answers with what ``answers`` maps
- its tool to, raising it when it's an exception. A session opens once ``release``, a ``threading.Event`` since the
- in-process proxy runs on a loop of its own, is set, when there is one."""
+ """Stands in for the proxy's ``ToolSession``: every env offers ``offered``, tools or their names, and a call answers
+ with what ``answers`` maps its tool to, raising it when it's an exception. A session opens once ``release``, a
+ ``threading.Event`` since the in-process proxy runs on a loop of its own, is set, when there is one."""
def __init__(self, offered, answers):
self.offered, self.answers, self.closed, self.release = offered, answers, 0, None
@@ -38,8 +38,9 @@ async def open(self):
return self
async def list_tools(self):
- return [EnvironmentTool(name=name, description=f"{name}.", inputSchema={"type": "object"})
- for name in tools.offered]
+ return [tool if isinstance(tool, EnvironmentTool)
+ else EnvironmentTool(name=tool, description=f"{tool}.", inputSchema={"type": "object"})
+ for tool in tools.offered]
async def call_tool(self, name, arguments=None):
answer = tools.answers.get(name, ToolResult(content=[{"type": "text", "text": f"{name} {arguments}"}]))
diff --git a/tst/unit/training/tools_test.py b/tst/unit/training/tools_test.py
index f65e00d59..a7c395f01 100644
--- a/tst/unit/training/tools_test.py
+++ b/tst/unit/training/tools_test.py
@@ -1,13 +1,30 @@
-"""The tool methods TRL calls an env's tools through: their signatures and docstrings from each tool's JSON Schema, the
-schema transformers' ``get_json_schema`` reads back from them, which is what TRL shows a model, and the tools refused."""
+"""The tool methods TRL finds an env's tools by: their signatures and docstrings from each tool's JSON Schema, the
+schema transformers' ``get_json_schema`` reads back from them, and the tools refused; and the ``SchemaTool`` that shows
+a model the tool as its env publishes it, rendered by Qwen3's chat template and parsed back from its tool call."""
import inspect
+import json
+from pathlib import Path
from typing import Any, Literal
import pytest
-from transformers.utils import get_json_schema
+from tokenizers import Tokenizer, models
+from transformers import PreTrainedTokenizerFast
+from transformers.utils import DocstringParsingException, get_json_schema
+from transformers.utils.chat_parsing import parse_response
-from agent_env.training._tools import tool_method
+from agent_env.training._tools import SchemaTool, tool_method
+
+QWEN3 = PreTrainedTokenizerFast(
+ tokenizer_object=Tokenizer(models.WordLevel({"[UNK]": 0}, unk_token="[UNK]")),
+ chat_template=(Path(__file__).parents[2] / "data" / "training" / "qwen3_chat_template.jinja").read_text())
+#: Qwen3's response template as TRL sets it on the tokenizer, which ``parse_response`` reads a completion with.
+QWEN3_RESPONSE = {"defaults": {"role": "assistant"}, "start_anchor": "<|im_start|>assistant\n", "fields": {
+ "reasoning_content": {"open": "", "close_pattern": r"\s*", "content": "text"},
+ "tool_calls": {"open": "", "close_pattern": r"\s*", "repeats": True, "content": "json",
+ "transform": {"type": "function", "function": "{content}"}},
+ "content": {"close_pattern": r"<\|im_end\|>\s*", "content": "text"},
+}}
RESTOCK = {"name": "restock", "description": "Restock a shelf.", "input_schema": {
"type": "object", "required": ["shelf", "count"],
@@ -82,3 +99,35 @@ def test_transformers_reads_back_each_tool_as_the_env_describes_it():
def test_a_tool_trl_cant_show_the_model_is_refused(tool: dict[str, Any], message):
with pytest.raises(ValueError, match=message):
tool_method(tool)
+
+
+def test_a_schema_tool_shows_the_model_its_envs_schema_nested_types_included_and_calls_through():
+ called = []
+ tool = SchemaTool(PLAN, lambda **arguments: called.append(arguments) or "planned")
+
+ assert shown_to_qwen3([tool]) == [{"type": "function", "function": {
+ "name": "check_plan", "description": "Check a plan.", "parameters": PLAN["input_schema"]}}]
+ [call] = called_by_qwen3([tool], {"name": "check_plan", "arguments": {"plan": json.dumps([{"ship": 3}])}})
+ assert call == {"type": "function", "function": {"name": "check_plan", "arguments": {"plan": [{"ship": 3}]}}}
+ assert tool(**call["function"]["arguments"]) == "planned"
+ assert called == [{"plan": [{"ship": 3}]}]
+ with pytest.raises(DocstringParsingException, match="for check_plan because it has no docstring"):
+ get_json_schema(tool)
+
+
+def shown_to_qwen3(tools: list[Any]) -> list[dict[str, Any]]:
+ """The tools Qwen3's chat template shows the model, as TRL renders a prompt."""
+ return tools_in(QWEN3.apply_chat_template([{"role": "user", "content": "Plan."}], tools=tools, tokenize=False))
+
+
+def tools_in(prompt: str) -> list[dict[str, Any]]:
+ return [json.loads(line) for line in prompt.split("\n")[1].split("\n")[0].splitlines()]
+
+
+def called_by_qwen3(tools: list[Any], *calls: dict[str, Any]) -> list[dict[str, Any]]:
+ """The tool calls ``parse_response`` reads from Qwen3's completion making ``calls``, typed by ``tools``' schemas as
+ TRL parses a completion."""
+ prompt = QWEN3.apply_chat_template([{"role": "user", "content": "Plan."}], tools=tools, tokenize=False,
+ add_generation_prompt=True)
+ completion = "\n\n\n\n" + "".join(f"\n{json.dumps(call)}\n" for call in calls)
+ return parse_response(completion + "<|im_end|>", QWEN3_RESPONSE, prefix=prompt, tools=tools)["tool_calls"]
diff --git a/tst/unit/training/trl_test.py b/tst/unit/training/trl_test.py
index c9a2cc9dc..17fc6d85b 100644
--- a/tst/unit/training/trl_test.py
+++ b/tst/unit/training/trl_test.py
@@ -24,6 +24,7 @@
from tst.unit.bundle._support import layout
from tst.unit.openenv.environment_test import AGAIN
from tst.unit.task.episode.run_test import ALICE, ITEMS, PANTRY, PANTRY_MCP, REPLIES, TURN, _tool_sessions
+from tst.unit.training.tools_test import called_by_qwen3, shown_to_qwen3
PLAYED = [ITEMS, ALICE, TURN, REPLIES]
SYSTEM = {"role": "system", "content": "You keep the items."}
@@ -327,6 +328,59 @@ def test_tasks_whose_agent_gets_other_tools_take_trls_multi_environment_mode(ser
assert pantry.get_reward() == 1.0
+STACK = EnvironmentTool(name="stack_crates", description="Stack crates on a pallet.", inputSchema={
+ "type": "object", "required": ["pallet", "crates"], "properties": {
+ "pallet": {"type": "string", "enum": ["north", "south"]},
+ "crates": {"type": "array", "description": "bottom crate first", "items": {
+ "type": "object", "required": ["kind"], "properties": {
+ "kind": {"type": "string", "description": "what the crate holds"},
+ "size": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}}}}}})
+#: What TRL shows the model of the tools ``add_item`` and ``STACK``: each as its env publishes it, and ``end_turn`` as
+#: TRL has shown it since the factory's first release, its method's schema.
+SHOWN = [
+ {"type": "function", "function": {"name": "add_item", "description": "add_item.", "parameters": {"type": "object"}}},
+ {"type": "function", "function": {
+ "name": "end_turn",
+ "description": "End your turn with your reply. The episode goes on to your next turn, or ends and is scored.",
+ "parameters": {"type": "object", "properties": {
+ "response": {"type": "string", "nullable": True, "description": "Your reply."}}},
+ "return": {"type": "string"}}},
+ {"type": "function", "function": {"name": "stack_crates", "description": "Stack crates on a pallet.",
+ "parameters": STACK.inputSchema}}]
+
+
+def test_trl_shows_the_model_each_tool_as_its_env_publishes_it_and_end_turn_as_before_and_calls_them(served, tools):
+ tools("add_item", STACK)
+ env = served({"a": PLAYED})
+ environment = env.factory()
+ fetched = [getattr(environment, name) for name in _tools(env.factory) if name not in ("reset", "get_reward")]
+
+ assert shown_to_qwen3(fetched) == SHOWN
+ from_the_class = env.factory.stack_crates.__get__(environment)
+ assert shown_to_qwen3([from_the_class])[0]["function"]["parameters"]["properties"]["crates"]["items"] == {
+ "type": "object"}
+ environment.reset(**env.dataset[0])
+ crates = [{"kind": "apples", "size": [1, 1, 0.5]}]
+ calls = called_by_qwen3(fetched, {"name": "stack_crates", "arguments": {"pallet": "north", "crates": crates}},
+ {"name": "end_turn", "arguments": {"response": "Added a crate."}})
+ by_name = {tool.__name__: tool for tool in fetched}
+
+ assert [by_name[call["function"]["name"]](**call["function"]["arguments"]) for call in calls] == [
+ f"stack_crates {{'pallet': 'north', 'crates': {crates}}}", ENDED]
+ assert environment.get_reward() == 1.0
+
+
+def test_a_transformers_that_renders_a_tool_other_than_as_its_schema_stops_the_factory(served, tools, monkeypatch):
+ tools(STACK)
+ env = served({"a": PLAYED})
+ monkeypatch.setattr(trl, "render_jinja_template", lambda conversations, tools, chat_template: (
+ [json.dumps([{**tool, "function": {**tool["function"], "parameters": {"type": "object"}}} for tool in tools])],
+ []))
+
+ with pytest.raises(RuntimeError, match=r"renders the tools \['stack_crates'\] other than as their env's"):
+ env.factory # noqa: B018
+
+
def test_a_tool_trl_cant_call_is_refused_with_how_to_fix_it(served, tools):
tools("add-item")
env = served({"a": PLAYED})
diff --git a/uv.lock b/uv.lock
index 0b9b4091d..1faf5c1b7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -145,6 +145,7 @@ tensorlake = [
trl = [
{ name = "datasets" },
{ name = "openenv" },
+ { name = "transformers" },
]
vercel = [
{ name = "anyio" },
@@ -206,7 +207,7 @@ requires-dist = [
{ name = "tenki", extras = ["async"], marker = "extra == 'tenki'", specifier = "~=1.4.1" },
{ name = "tensorlake", marker = "extra == 'tensorlake'", specifier = "~=0.5.140" },
{ name = "tomli-w", marker = "extra == 'dev'", specifier = "==1.2.0" },
- { name = "transformers", marker = "extra == 'dev'", specifier = ">=5.2" },
+ { name = "transformers", marker = "extra == 'trl'", specifier = ">=5.2" },
{ name = "urllib3", marker = "extra == 'boat'", specifier = ">=2.1.0,<3" },
{ name = "uvicorn", specifier = ">=0.47" },
{ name = "vercel-sandbox", marker = "extra == 'vercel'", specifier = ">=0.7.0,<0.8" },