> ## Documentation Index
> Fetch the complete documentation index at: https://docs.openreward.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Using Terminal Tools

> Grade the final assistant message instead of a submit tool call

## 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

* An OpenReward [account](https://openreward.ai/)
* An OpenReward [API key](https://openreward.ai/keys)
* Completion of the [Your First Environment](/environments/your-first-environment) tutorial

## 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.

```python theme={null}
from openreward.environments import Environment, terminal, tool, ToolOutput, TextBlock
from pydantic import BaseModel, Field

class FinalAnswer(BaseModel):
    answer: str = Field(..., description="The assistant's final message")

class QAEnvironment(Environment):
    @tool
    async def search(self, params: SearchParams) -> ToolOutput:
        """Search the knowledge base"""
        ...

    @terminal
    @tool
    async def grade(self, params: FinalAnswer) -> ToolOutput:
        """Grade the assistant's final message"""
        correct = params.answer.strip().lower() == self.task_spec["answer"].lower()
        return ToolOutput(
            blocks=[TextBlock(text="Correct!" if correct else "Incorrect.")],
            reward=1.0 if correct else 0.0,
            finished=True,
        )
```

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.

```python theme={null}
    @terminal
    @tool
    async def finish(self) -> ToolOutput:
        """Run the test suite against whatever the agent left behind"""
        passed = await self.sandbox.run("pytest -q")
        return ToolOutput(blocks=[TextBlock(text="graded")], reward=..., finished=True)
```

`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](/environments/using-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:

```python theme={null}
from openreward import AsyncOpenReward

client = AsyncOpenReward()
environment = client.environments.get("YourOrg/qa-env")
tasks = await environment.list_tasks(split="train")

async with environment.session(tasks[0]) as session:
    tools = await session.list_tools(format="anthropic")   # terminal tool not included
    assistant_ends_rollout = await session.is_assistant_message_final()

    while True:
        response = await call_your_model(messages, tools)

        if response.tool_calls:
            for call in response.tool_calls:
                output = await session.call_tool(call.name, call.input)
                messages.append(...)
            continue

        # No tool calls. In a terminal-tool environment this message is the answer.
        if assistant_ends_rollout:
            output = await session.call_terminal_tool(response.text)
            print(output.reward)
        break
```

`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:

```python theme={null}
await environment.is_assistant_message_final()
await environment.terminal_tool()   # TerminalToolSpec(name=..., arg=..., description=...) or None
```

Prefer the session methods inside a rollout — they reuse the session's cached tool list and account for any [session toolset](/harnesses/harness-toolsets), whose terminal tool takes precedence over the environment's.

## Calling it from environment code

Inside the environment, `call_terminal_tool()` does the same routing:

```python theme={null}
result = await env.call_terminal_tool("Paris")
```

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.
