Guide · Agent tool calls
AvailableLast verified
Test the whole agent loop, not a canned answer
A workflow fixture answers in two phases. The first request gets tool calls with stable IDs and arguments; the follow-up request that carries the tool results gets the final answer. Your orchestration code, argument parsing, tool dispatch, and message threading all run for real.
In this guide
- How the two phases are chosen
- Single tool round trip end to end
- Parallel tool calls
- Assert the loop from tests
Prerequisites
- An LLM DotMock workspace, or a local dotmock.yaml served by dotmock serve
- The workspace ID in $API_ID and its runtime URL in $MOCK_URL (SDK base URL $MOCK_URL/v1 for OpenAI-compatible clients)
On this page
How the two phases work
Phase 1: when the matched request has no tool result for the workflow's calls, DotMock returns workflow.run.toolCalls with finish reason tool_calls (tool_use for Anthropic). Streamed OpenAI responses send the tool-call header delta first, then the arguments split by the streaming chunkSize.
Phase 2: when the request contains a tool message whose tool_call_id matches one of the run calls (or, when the run calls have no IDs, any tool message), DotMock serves workflow.response with tool placeholders resolved.
- Each call is { id, name, arguments, result }; arguments is a JSON string
- result is the mocked tool return value used when the client's tool message is empty, which lets the built-in tester finish the round trip on its own
- {{tool.result}}, {{tool.<name>.result}}, and {{tool.<name>.args.<path>}} resolve in the final response
- Match with toolName so the fixture only fires when the client actually offered that tool
A complete single-tool example
This fixture fires whenever the request defines a get_weather tool. The same fixture serves both turns of the conversation.
{
"name": "weather-tool-call",
"priority": 20,
"match": { "toolName": "get_weather" },
"workflow": {
"run": {
"toolCalls": [
{
"id": "call_weather_1",
"name": "get_weather",
"arguments": "{\"city\":\"Paris\",\"unit\":\"celsius\"}",
"result": "{\"temperatureC\":18,\"condition\":\"sunny\"}"
}
]
},
"response": {
"content": "It is {{tool.get_weather.result}} in {{tool.get_weather.args.city}}.",
"finishReason": "stop"
}
}
}Drive it from the real client
The application code below is ordinary OpenAI tool-calling code. Nothing in it knows about DotMock except the base URL.
import OpenAI from "openai";
const client = new OpenAI({ baseURL: `${process.env.MOCK_URL}/v1`, apiKey: "dotmock" });
const tools = [{
type: "function" as const,
function: { name: "get_weather", parameters: { type: "object", properties: { city: { type: "string" } } } },
}];
const messages: OpenAI.ChatCompletionMessageParam[] = [
{ role: "user", content: "What's the weather in Paris?" },
];
const first = await client.chat.completions.create({ model: "gpt-4o-mini", tools, messages });
const call = first.choices[0].message.tool_calls![0]; // id: call_weather_1
const result = await runTool(call.function.name, JSON.parse(call.function.arguments));
messages.push(first.choices[0].message, {
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(result),
});
const final = await client.chat.completions.create({ model: "gpt-4o-mini", tools, messages });
console.log(final.choices[0].message.content);Expected result: The first response has finish_reason tool_calls and one get_weather call with city Paris; the final content contains your tool's actual result and "Paris".
Parallel tool calls
List several calls in run.toolCalls to return them in one assistant turn, as models do for parallel function calling. Phase 2 fires once a tool result for any listed call ID arrives, so send all results in the same follow-up request as real clients do. Reference each result by tool name in the final response.
{
"name": "trip-planner",
"match": { "toolName": "get_flights", "userMessage": "trip" },
"workflow": {
"run": {
"toolCalls": [
{ "id": "call_flights", "name": "get_flights", "arguments": "{\"to\":\"LIS\"}", "result": "{\"price\":212}" },
{ "id": "call_hotels", "name": "get_hotels", "arguments": "{\"city\":\"Lisbon\",\"nights\":3}", "result": "{\"price\":390}" }
]
},
"response": {
"content": "Flights: {{tool.get_flights.result}}. Hotel for {{tool.get_hotels.args.nights}} nights: {{tool.get_hotels.result}}."
}
}
}Branch on tool results and turns
Use additional fixtures with a lower priority number to branch the loop. toolCallId matches the call the last tool message answers; hasToolResult separates first turns from follow-ups; a $.messages gjson condition can inspect the returned tool content; turnIndex limits a fixture to a given number of user turns; sequenceIndex returns a different plan on a retry.
{
"name": "weather-tool-failed",
"priority": 15,
"match": {
"toolCallId": "call_weather_1",
"conditions": [{ "field": "$.messages.#(role==\"tool\").content", "operator": "contains", "value": "error" }]
},
"response": { "content": "I couldn't reach the weather service. Try again shortly." }
}Assert the loop in tests
A complete round trip produces two journal entries for the workflow fixture, one per phase. In local mode, expectFixtureMatched("weather-tool-call", { times: 2 }) from @dotmock/cli/testing or dotmock.assert_fixture_matched("weather-tool-call", times=2) in pytest proves the loop ran to completion rather than stopping after the tool call.