Skip to main content

Goals

  • Understand when to grade a plain assistant message rather than a tool call
  • Mark a tool with @terminal so it is hidden from the model
  • Use session.is_assistant_message_final() and session.call_terminal_tool() in a rollout loop

Prerequisites

Introduction

Most OpenReward environments end an episode on a tool call: the model calls submit_answer(answer="..."), the environment grades it, and the rollout stops. The environment decides when the episode is over, because it is the environment that sees the final action. But in many cases the last thing an agent should do is write a message, not call a tool — reporting to the user that the changes have been made to the code, answering a question, summarising what it found. Forcing a submit call there is an artificial extra step, and it means the environment never gets to see the thing you actually want to grade. When the final action is an assistant message, the termination event happens on the harness side, not the environment side: the environment can’t observe a message with no tool calls, so only the harness knows the episode has ended. Terminal tools exist to accommodate this. You mark one tool with @terminal, the harness recognises that a message with no tool calls ends the rollout, and it hands that message back to the environment for grading. Concretely, marking a tool with @terminal means:
  • The tool is hidden from environment.list_tools() and session.list_tools() — the model never sees it and cannot call it directly.
  • is_assistant_message_final() returns True. 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 call submit and needs nudging.
  • The harness passes that message’s text to session.call_terminal_tool(text), which routes it into your grading tool.
The result: the model just writes its answer, and you still get a normal 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.
Here 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:
  • @tool is still required. @terminal marks an existing tool as terminal; it does not declare one.
  • At most one terminal tool, counting tools contributed by declared toolsets. Two @terminal tools is an error.
  • At most one argument. The input model may declare one field (which receives the message) or none. Extra fields like confidence are 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:
Prefer the session methods inside a rollout — they reuse the session’s cached tool list and account for any session toolset, whose terminal tool takes precedence over the environment’s.

Calling it from environment code

Inside the environment, call_terminal_tool() does the same routing:
The tool also remains callable by name (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.