Goals
- Understand when to grade a plain assistant message rather than a tool call
- Mark a tool with
@terminalso it is hidden from the model - Use
session.is_assistant_message_final()andsession.call_terminal_tool()in a rollout loop
Prerequisites
- An OpenReward account
- An OpenReward API key
- Completion of the Your First Environment tutorial
Introduction
Most environments end a rollout with a submit-style tool call: the model callssubmit_answer(answer="...") and the environment grades it. That works, but it forces the model through a tool call it would not otherwise make — and for many tasks (QA, summarisation, reasoning, chat) the answer is simply the model’s last message.
A terminal tool inverts this. You mark one tool with @terminal, and:
- The tool is hidden from
environment.list_tools()andsession.list_tools()— the model never sees it and cannot call it directly. is_assistant_message_final()returnsTrue. A harness checks this once, up front, to learn that in this environment a message with no tool calls is the finished answer — rather than a model that forgot to callsubmitand needs nudging.- The harness passes that message’s text to
session.call_terminal_tool(text), which routes it into your grading tool.
ToolOutput with a reward.
Declaring a terminal tool
@terminal sits on top of @tool — it marks an existing tool as terminal rather than declaring one, so @tool is still required. (The two are order-independent, but reading @terminal first says what the tool is before how it’s wired.) The tool accepts one argument: a Pydantic model with a single field, which receives the assistant’s message. The field can be named whatever reads best.
session.list_tools() returns only search. An environment whose only tool is the terminal tool is valid too — that is the pure “answer the question” case, with no tools at all shown to the model.
Terminal tools with no arguments
Sometimes the message itself doesn’t matter: the model works in a sandbox, says “done”, and grading runs against environment state. Give the terminal tool no arguments and the message is simply dropped.call_terminal_tool(text) still works — the text is ignored — and call_terminal_tool() is valid too.
Rules
An environment fails at server startup if any rule is broken:@toolis still required.@terminalmarks an existing tool as terminal; it does not declare one.- At most one terminal tool, counting tools contributed by declared toolsets. Two
@terminaltools is an error. - At most one argument. The input model may declare one field (which receives the message) or none. Extra fields like
confidenceare an error — there is only one thing to pass in.
Using it in a rollout
The client reports the environment’s choice, so a harness can support both styles with the same loop:is_assistant_message_final() returns False for environments without a terminal tool, in which case a message with no tool calls means whatever your harness already does with it. Environments served by an SDK version older than 0.1.139 also report False.
Both methods are available on the sync client (Session) and the async client (AsyncSession), and on the environment itself:
Calling it from environment code
Inside the environment,call_terminal_tool() does the same routing:
session.call_tool("grade", {"answer": "Paris"})) — hiding it from the tool list stops the model from discovering it, not your own code from using it.
