Build
Loop and exit
Feed tool results back to SID-1, repeat, and stop when it reports its ranked IDs.


In the previous pages we've covered how SID-1 generates a turn, you run its tool calls against your backend, and you append the results. Now we'll cover the loop that repeats those steps until SID-1 reports its ranked IDs or you hit a turn cap.
The loop


First, dispatch each tool call by name. Parse the arguments JSON string, call the matching backend function from the previous page, and return either formatted tool content or the final reported IDs.
import json
def execute_tool_call(tc) -> tuple[str, list[str] | None]:
name = tc.function.name
arguments = json.loads(tc.function.arguments)
if name == "search":
docs = search(arguments["query"], arguments.get("limit", 5))
return format_docs_xml(docs), None
if name == "text_search":
docs = text_search(arguments["query"], arguments.get("limit", 5))
return format_docs_xml(docs), None
if name == "read":
doc = read(arguments["id"])
return format_docs_xml([doc]), None
if name == "report_helpful_ids":
ids = arguments["ids"]
return json.dumps(ids), ids
return f"Unknown tool: {name}", Nonetype ToolResult = { text: string; ids: string[] | null };
async function executeToolCall(
tc: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
): Promise<ToolResult> {
const name = tc.function.name;
const args = JSON.parse(tc.function.arguments);
if (name === "search") {
const docs = await search(args.query, args.limit ?? 5);
return { text: formatDocsXml(docs), ids: null };
}
if (name === "text_search") {
const docs = await textSearch(args.query, args.limit ?? 5);
return { text: formatDocsXml(docs), ids: null };
}
if (name === "read") {
const doc = await read(args.id);
return { text: formatDocsXml([doc]), ids: null };
}
if (name === "report_helpful_ids") {
return { text: JSON.stringify(args.ids), ids: args.ids };
}
return { text: `Unknown tool: ${name}`, ids: null };
}The loop skeleton below shows where that dispatcher fits. The complete runnable version is in the quickstart.
MAX_TURNS = 10
def turn_budget_notice(turns_left: int) -> str:
notice = f"\n\nYou have {turns_left} out of {MAX_TURNS} turns left."
if turns_left == 1:
notice += " You must call `report_helpful_ids` in the next turn."
return notice
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": query},
]
reported_ids = None
for turn in range(MAX_TURNS):
response = client.chat.completions.create(model="sid-1", messages=messages, tools=tools)
assistant_msg = response.choices[0].message
messages.append(assistant_msg) # see the quickstart for exact serialization
if not assistant_msg.tool_calls:
break # rare; see "When there's no tool call"
for tc in assistant_msg.tool_calls:
result_text, ids = execute_tool_call(tc) # runs your search backend
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result_text})
if ids is not None:
reported_ids = ids
if reported_ids is not None:
break
# Tell SID-1 how many turns remain on the last tool message of the turn.
turns_left = MAX_TURNS - (turn + 1)
if turns_left >= 1:
messages[-1]["content"] += turn_budget_notice(turns_left)
result = reported_ids or []const MAX_TURNS = 10;
function turnBudgetNotice(turnsLeft: number): string {
let notice = `\n\nYou have ${turnsLeft} out of ${MAX_TURNS} turns left.`;
if (turnsLeft === 1) {
notice += " You must call `report_helpful_ids` in the next turn.";
}
return notice;
}
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: query },
];
let reportedIds: string[] | null = null;
for (let turn = 0; turn < MAX_TURNS; turn++) {
const response = await client.chat.completions.create({ model: "sid-1", messages, tools });
const assistantMsg = response.choices[0].message;
messages.push(assistantMsg);
if (!assistantMsg.tool_calls?.length) break; // rare; see "When there's no tool call"
let lastToolMessage: OpenAI.Chat.Completions.ChatCompletionToolMessageParam | null = null;
for (const tc of assistantMsg.tool_calls) {
const { text, ids } = await executeToolCall(tc); // runs your search backend
const toolMessage = { role: "tool" as const, tool_call_id: tc.id, content: text };
messages.push(toolMessage);
lastToolMessage = toolMessage;
if (ids) reportedIds = ids;
}
if (reportedIds) break;
// Tell SID-1 how many turns remain on the last tool message of the turn.
const turnsLeft = MAX_TURNS - (turn + 1);
if (turnsLeft >= 1 && lastToolMessage) {
lastToolMessage.content += turnBudgetNotice(turnsLeft);
}
}
const result = reportedIds ?? [];Append results and continue
After you run a turn's tool calls, append the assistant message and one tool message per result to the message list, then call chat.completions again. SID-1 reads what you returned and chooses the next search. Append results verbatim, the same way you format them on your backend page.
Exit on report_helpful_ids
The loop ends when SID-1 calls report_helpful_ids. Its ids argument is the answer: a ranked list of document IDs, most helpful first (see ID rules). Return that list from your code in the order SID-1 gave it.
Bound the loop with MAX_TURNS
Always cap the number of turns so a run terminates even if report_helpful_ids is never called. Return an empty list when you hit the cap, and treat it as a miss in your metrics.
Tell SID-1 how many turns remain
SID-1 chooses when to stop searching and report. Give it the information to time that choice: after you append a turn's tool messages, add a turn-budget notice to the content of the last tool message, separated from the documents by a blank line. SID-1 reads tool messages in order, so this notice is the last thing it sees before its next turn.
The notice states how many turns are left:
You have 3 out of 10 turns left.When one turn remains, add a second sentence so SID-1 reports before the loop hits the cap and returns nothing:
You have 1 out of 10 turns left. You must call `report_helpful_ids` in the next turn.The turn_budget_notice helper in the loop above builds both cases. Append it once per turn, to the last tool message only, so the count appears a single time. The turn budget is computed in the loop, not by your result formatter, because the count depends on the turn number.
If a search comes back empty or your backend times out, return a natural-language note in that tool message instead of empty content (see handle empty results and timeouts). SID-1 reads the note and adjusts its next query.