Advanced Custom Client Patterns

June 25, 2026 ยท View on GitHub

Use this reference after you understand Use a Custom Client + Agent. The main guide shows the recommended stateless replay path. This page covers agents that keep provider or framework state on self.

STATE-Bench constructs a fresh agent instance for every task. Any state you keep on self is scoped to that task and is discarded afterward.

Tool-Loop Reminder

On the first generate_next_turn() call for an agent turn, the last conversation item is the new user message. If your response requests tools, STATE-Bench executes them and calls generate_next_turn() again with a temporary role: "tool" item appended after the assistant tool-call message.

That means stateful agents must handle both cases:

last = conversation[-1]
if last.get("role") == "tool":
    # Follow-up after STATE-Bench executed requested tools.
    tool_results = last.get("content", [])
else:
    # New user turn.
    user_message = last.get("content", "")

The saved trajectory remains the canonical transcript for scoring. The temporary role: "tool" item is only part of the working conversation passed back to your agent during tool execution.

Server-Side Stateful Provider

Use this when your provider offers a stateful conversation primitive, such as a response ID, thread ID, or session ID. Cache the provider handle on self, send the latest user turn on the first call, then send tool results on follow-up calls.

# agents/my_stateful_agent.py
from state_bench.agents.base import AgentToolCallRequest, AgentTurnResponse, BaseAgent


class MyStatefulAgent(BaseAgent):
    def __init__(self, client, system_prompt, tools, tool_handlers, runtime_context=None, **kwargs):
        super().__init__(runtime_context=runtime_context)
        self.client = client
        self.system_prompt = system_prompt
        self.tools = tools
        self._previous_response_id: str | None = None

    def _latest_input(self, conversation):
        last = conversation[-1]
        if last.get("role") == "tool":
            return {"tool_results": last.get("content", [])}
        return {"user_message": last.get("content", "")}

    def generate_next_turn(self, *, system_prompt, conversation, tools):
        response = self.client.generate(
            system_prompt=system_prompt,
            input=self._latest_input(conversation),
            tools=tools,
            previous_response_id=self._previous_response_id,
        )
        self._previous_response_id = response.id

        if getattr(response, "cost_usd", None) is not None:
            self.add_cost_usd(response.cost_usd)
        self.add_token_usage(
            input_tokens=getattr(response.usage, "input_tokens", None),
            output_tokens=getattr(response.usage, "output_tokens", None),
            cached_input_tokens=getattr(response.usage, "cached_input_tokens", None),
        )
        return AgentTurnResponse(
            text=response.text,
            tool_calls=[
                AgentToolCallRequest(name=c.name, arguments=c.arguments)
                for c in getattr(response, "tool_calls", [])
            ],
        )

Your provider may require tool results in a different shape. Convert last["content"], which is a list of {name, arguments, result} records, to the provider's expected tool-result format before sending it.

Framework-Native History

Use this when an agent framework owns its own message history, such as LangChain, AutoGen, Semantic Kernel, or another framework runtime. Append user turns and tool results as they arrive; the framework owns replay.

# agents/my_framework_agent.py
from state_bench.agents.base import AgentToolCallRequest, AgentTurnResponse, BaseAgent


class MyFrameworkAgent(BaseAgent):
    def __init__(self, client, system_prompt, tools, tool_handlers, runtime_context=None, **kwargs):
        super().__init__(runtime_context=runtime_context)
        self.framework_agent = client.build_framework_agent(system_prompt=system_prompt, tools=tools)
        self._native_history: list = []  # framework's native message type

    def _append_latest_to_history(self, conversation):
        last = conversation[-1]
        if last.get("role") == "tool":
            for call in last.get("content") or []:
                self._native_history.append({"role": "tool", "name": call["name"], "content": call["result"]})
            return
        self._native_history.append({"role": "user", "content": last.get("content", "")})

    def generate_next_turn(self, *, system_prompt, conversation, tools):
        self._append_latest_to_history(conversation)

        result = self.framework_agent.run(messages=self._native_history)
        self._native_history = result.messages  # framework's updated native history

        if getattr(result, "cost_usd", None) is not None:
            self.add_cost_usd(result.cost_usd)
        self.add_token_usage(
            input_tokens=getattr(result.usage, "input_tokens", None),
            output_tokens=getattr(result.usage, "output_tokens", None),
            cached_input_tokens=getattr(result.usage, "cached_input_tokens", None),
        )
        return AgentTurnResponse(
            text=result.text,
            tool_calls=[
                AgentToolCallRequest(name=c.name, arguments=c.arguments)
                for c in getattr(result, "tool_calls", [])
            ],
        )

Because instances are per-task, self._native_history starts empty for every task. No manual reset is needed.

Next Step

Return to Use a Custom Client + Agent, then continue with your track guide.