Build

Loop and exit

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

The SID-1 retrieval pipeline with the results stage highlighted. A question feeds into SID-1; SID-1 exchanges repeated tool calls (BM25, ANN, and similar) with a search backend and receives content and metadata back; SID-1 returns ranked results. A dashed box labeled 'Loop and exit' encloses the results box, marking the part this page covers: looping until SID-1 reports its ranked document IDs and returning that list.

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

A flowchart of the SID-1 agent retrieval loop. It begins at a Start terminal that flows down into a 'SID-1 generates' step, where the model produces the next assistant turn. From there the flow reaches a 'Tool calls?' decision diamond. The expected Yes branch goes to 'Execute tool calls in parallel', then to a 'Called report_helpful_ids?' decision diamond. If the turn called report_helpful_ids (Yes), the flow goes to 'Return reported document IDs'; if it did not (No), it routes into 'Max turns reached?'. The rare No branch from 'Tool calls?' also routes into 'Max turns reached?'. The 'Max turns reached?' diamond closes the loop: on No it returns left to 'SID-1 generates' for another turn, and on Yes it goes to 'Return empty list'. Both 'Return reported document IDs' and 'Return empty list' converge into a single End terminal.

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}", None
type 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.

Content