Crown Citadel Group Ciru Inference Lab llm.ciru.ai / research Research Index

Crown Citadel Research Report

StepFun Tool Calling: Why Eval Loops Happen and How To Fix Them

A practical explanation of the tool-call message loop for StepFun Step 3.7 Flash, why common OpenAI-compatible harnesses can misformat the continuation turn, and the exact template and client changes that make multi-step tool evaluations behave normally.

Summary

Tool calling is not magic inside the model. A serving layer or benchmark harness sends text to the model, parses a tool request from the model output, runs the real tool, inserts the tool result back into the conversation, and asks the model to continue.

For StepFun Step 3.7 Flash, the continuation format matters. The model expects the tool result to come back as a user-side observation wrapped in <tool_response>. If the result is rendered as a separate custom role, or if the harness asks the model to make another required tool call after the tool result is already present, the model can loop, repeat tools, or fail multi-turn eval tasks that otherwise should be straightforward.

3 phasesUser request, assistant tool call, user-side tool response.
2 fixesPatch the chat template and patch the tool-choice loop.
1 ruleAfter the tool result is inserted, the next assistant turn should be allowed to answer.
Observed guardCalculator loop case resolved to 2 turns and 1 tool call.

The Mental Model

A local LLM does not call your calculator, browser, shell, calendar, or email API directly. It emits a structured string that your wrapper interprets as a tool request. Your wrapper is responsible for the rest.

User asks a question. Example: "What is 123 * 456?"
The model requests a tool. The assistant outputs a <tool_call> block naming the function and parameters.
Your code runs the real tool. The calculator, browser, database, or API executes outside the model.
Your code appends the result. The tool output is inserted back into the model's chat history.
The model continues. It reads the tool result and either calls another tool or gives the final answer.
Important distinction: API message roles such as user, assistant, and tool are not what the model literally sees. The model sees the final prompt text produced by the chat template. If the template turns a tool message into the wrong text shape, the model receives the wrong protocol.

What StepFun Expects

The working StepFun pattern is: assistant emits <tool_call>, then the environment returns the result as a user-side <tool_response> observation, then the assistant continues.

Minimal Conversation

<|im_start|>user
What is 123 * 456?
<|im_end|>
<|im_start|>assistant
<tool_call>
<function=calculator>
<parameter=expression>
123 * 456
</parameter>
</function>
</tool_call>
<|im_end|>
<|im_start|>user
<tool_response>
{"result": 56088}
</tool_response>
<|im_end|>
<|im_start|>assistant
The answer is 56,088.
<|im_end|>

Equivalent Structured Messages

If your application works with JSON-style messages, the wrapper can represent the tool result as a user message containing the response block:

[
  {
    "role": "user",
    "content": "What is 123 * 456?"
  },
  {
    "role": "assistant",
    "content": "<tool_call>...</tool_call>"
  },
  {
    "role": "user",
    "content": "<tool_response>\n{\"result\": 56088}\n</tool_response>"
  }
]

Frameworks may use {"role": "tool"} internally. That is acceptable only if the active chat template converts that tool role into the StepFun-compatible user-side <tool_response> form before the prompt reaches the model.

Why This Breaks Evals

Many tool benchmarks use OpenAI-compatible client objects and expect the server template to translate those objects into the model's native format. This often works for models trained to tolerate OpenAI-style tool roles. StepFun is stricter: it expects the tool result to appear in the dialogue grammar it learned.

Harness Behavior What StepFun Receives Eval Symptom
Tool output is rendered as a standalone custom role. <|im_start|>tool_response ... instead of user <tool_response>. The model may not treat the data as the observation that resolves the previous call.
The follow-up generation asks for a required tool call. The model is instructed that the next assistant message must be another call. Repeated tool calls, loops, or missing final answers.
Thinking is disabled for a model that expects thinking format. The continuation prompt no longer matches the model's normal response grammar. Unstable tool selection, malformed continuations, or worse state tracking.
Why this looks like model quality failure: a benchmark may score the model as bad at planning, state, or instruction following. Sometimes that is real. But if the harness feeds tool results back in the wrong grammar, the model is being tested through a broken protocol.

The Fix

Fix 1

Render tool outputs as user observations

The chat template should transform a tool result into a user message containing <tool_response>.

{% elif message.role == "tool" %}
<|im_start|>user
<tool_response>
{{ message.content }}
</tool_response>
<|im_end|>
{% endif %}
Fix 2

Use automatic choice after the result

When a tool is required for the first step, request a tool call. After appending the tool result, let the model either answer or call another tool.

first_model_call:
  tool_choice = "required"

after_tool_result:
  tool_choice = "auto"
The operational rule: a forced tool call is useful when the task requires the model to begin by selecting a tool. Once the tool result is available in the conversation, the continuation turn should be free to answer.

How To Apply It

Step 1: Find the active chat template

In llama.cpp-style servers, the template may come from the GGUF metadata, a server flag, or a launch profile. You are looking for the logic that handles message.role == "tool", tool_calls, or custom tool-response roles.

The target behavior is simple: tool calls are produced by the assistant, and tool results are rendered as a user message with a <tool_response> block.

Step 2: Patch tool-result rendering

If the template currently emits a custom role, change it to the user-observation form.

Do Not Render Tool Results As Render Them As
<|im_start|>tool_response
{"result": 56088}
<|im_end|>
<|im_start|>user
<tool_response>
{"result": 56088}
</tool_response>
<|im_end|>

Step 3: Patch the client or harness loop

The tool loop should append the assistant's tool call, run the tool, append the result, and call the model again with automatic tool choice.

messages = [
    {"role": "user", "content": "What is 123 * 456?"}
]

response = call_model(
    messages=messages,
    tools=tools,
    tool_choice="required",
)

tool_call = parse_tool_call(response)
tool_result = run_tool(tool_call)

messages.append({
    "role": "assistant",
    "content": response.text,
})

messages.append({
    "role": "user",
    "content": "<tool_response>\n" + tool_result + "\n</tool_response>",
})

final = call_model(
    messages=messages,
    tools=tools,
    tool_choice="auto",
)

Step 4: Keep StepFun thinking mode compatible

For StepFun Step 3.7 Flash, use the normal thinking-compatible format unless the model provider explicitly documents a no-thinking mode for that exact model. Changing the reasoning grammar and the tool grammar at the same time makes debugging much harder.

Verification Checks

After applying the fix, test a small deterministic tool case before running a full benchmark.

Check Expected Result
Single calculator call The model calls the calculator once, reads the result, and answers.
Two-step tool chain The model can call a second tool only when the first result actually requires it.
Tool-result prompt inspection The serialized prompt contains <|im_start|>user followed by <tool_response>.
Loop regression A task that previously repeated a tool now finishes with a final assistant answer.
Avoid false fixes: shorter token limits, arbitrary stop strings, or timeouts can hide loops without fixing the conversation protocol. The durable fix is to make the template and tool loop match the model's expected grammar.

In the local StepFun Step 3.7 Flash ROCmFP4 MTP evaluation, this protocol fix cleared the repeated calculator-call guard case and the full Tool Eval run completed at 88/100 with no detected overgeneration scenarios.