Research preview

Agent loop

One SID-2 episode begins with a question and ends with ranked IDs. The question reaches SID-2 along an arrow labeled + tools. SID-2 sends search and read tool calls to your search backend and episode cache, which return snippets or chunks and metadata. Parallel arrows in both directions represent multiple calls across turns. The report comes from SID-2, not directly from the backend. An outer dashed box labeled Agent loop encloses the entire process: the question, SID-2, tool calls and observations, the search backend and episode cache, and the final ranked IDs. Flowchart for one SID-2 episode. Start episode flows to SID-2 generates, then Search / read tool calls? Yes branches to Fetch in parallel and Render in call order, then to Max turns reached or incomplete? No continues down to Called report_helpful_ids? A report returns the reported document IDs and ends the episode. Without a report, check Max turns reached or incomplete? Yes ends without a report; No loops back to SID-2 generates. The continuation path runs beside the main downward path, and both exits converge at End episode.

Executing a turn's tool calls

SID-2 usually makes several tool calls per turn. The reference implementation splits execution in two:

  1. Fetch in parallel. The backend queries behind a turn's search/text_search calls are independent, so run them concurrently (the Python reference uses a ThreadPoolExecutor capped at MAX_PARALLEL_FETCHES = 8; set the cap to 1 to keep your backend serial).

    The thread pool is right for one episode at a time. If you run many episodes concurrently (an evaluation, a batch job) a pool per episode multiplies out fast (100 episodes x 8 workers = 800 threads, all of them blocked on sockets). Prefer asyncio there: make each episode a coroutine, gate them with a Semaphore, and share one connection pool across all of them. The TypeScript reference uses async workers with the same cap on concurrent backend queries. SDK methods are synchronous.

  2. Render serially, in call order. Compose your observations serially because the seen-ledger is stateful: a span shown by an earlier call in the turn is collapsed to [seen: ...] in later ones.

The rendering step uses the same SDK calls as formatting documents:

from sid import DocumentCache
from sid.document_cache import SNIPPET_SIZE_DEFAULT


def render_results(cache, results, query, snippet_size=SNIPPET_SIZE_DEFAULT):
    rendered = []
    for result in results:
        document = dict(result)
        data_id = document.pop("id")
        cache.add_document(data_id, document)
        view = cache.apply_snippet(
            data_id,
            snippet_field="content",
            query=query,
            snippet_size=snippet_size,
        )
        rendered.append(view.render_xml())
        cache.update_seen(view)

    return "\n".join(rendered) if rendered else "No results found. Try a different query."
import { DocumentCache, SNIPPET_SIZE_DEFAULT } from "@sid-ai/sid-sdk";

type SearchResult = { id: string; content: string; [field: string]: unknown };

function renderResults(cache: DocumentCache, results: SearchResult[], query: string, snippetSize = SNIPPET_SIZE_DEFAULT): string {
  const rendered: string[] = [];
  for (const { id: dataId, ...document } of results) {
    cache.addDocument(dataId, document);
    const view = cache.applySnippet(dataId, {
      snippetField: "content", query, snippetSize,
    });
    rendered.push(view.renderXml());
    cache.updateSeen(view);
  }
  return rendered.join("\n") || "No results found. Try a different query.";
}
import sid "github.com/sidhq/sid-go"

func renderResults(cache *sid.DocumentCache, results []map[string]any, query string, snippetSize int) (string, error) {
	var rendered []string
	for _, result := range results {
		document := maps.Clone(result)
		dataID := document["id"].(string)
		delete(document, "id")
		if _, err := cache.AddDocument(dataID, document); err != nil {
			return "", err
		}
		view, err := cache.ApplySnippet(dataID, sid.ApplySnippetOptions{
			SnippetField: "content", Query: query, SnippetSize: snippetSize,
		})
		if err != nil {
			return "", err
		}
		xml, err := view.RenderXML()
		if err != nil {
			return "", err
		}
		rendered = append(rendered, xml)
		if err := cache.UpdateSeen(view); err != nil {
			return "", err
		}
	}
	if len(rendered) == 0 {
		return "No results found. Try a different query.", nil
	}
	return strings.Join(rendered, "\n"), nil
}
fetches = [pool.submit(fetch_results, call.name, args) for call, args in zip(calls, arguments)]

# Compose observations serially, in call order: what earlier calls show, later calls mask.
for call, args, fetch in zip(calls, arguments, fetches):
    observation = render_results(
        cache, fetch.result(), args["query"], args.get("snippet_size", SNIPPET_SIZE_DEFAULT)
    )
    ...
// Fetch search results concurrently, then render in call order.
const fetches = await Promise.all(calls.map((call, i) => fetchResults(call.name, args[i])));
for (let i = 0; i < calls.length; i++) {
  const observation = renderResults(
    cache, fetches[i], args[i].query, args[i].snippet_size ?? SNIPPET_SIZE_DEFAULT,
  );
  // Append the observation for this call.
}
// Fetch search results concurrently, then render in call order.
fetched := make([][]map[string]any, len(calls))
fetchErrors := make([]error, len(calls))
var wg sync.WaitGroup
for i, call := range calls {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fetched[i], fetchErrors[i] = fetchResults(ctx, call.Name, args[i])
	}()
}
wg.Wait()

for i := range calls {
	if fetchErrors[i] != nil {
		log.Fatal(fetchErrors[i])
	}
	query, _ := args[i]["query"].(string)
	snippetSize := sid.SnippetSizeDefault
	if size, ok := args[i]["snippet_size"].(float64); ok {
		snippetSize = int(size)
	}
	observation, err := renderResults(cache, fetched[i], query, snippetSize)
	if err != nil {
		log.Fatal(err)
	}
	// Append the observation for this call.
}

read calls never touch the backend: they are served from the DocumentCache, which already holds the full text of every chunk a search returned.

Tell SID-2 how many turns remain

SID-2 chooses when to stop searching and report; the turn-budget message gives it the information to time that choice. Send it as a user message after every non-terminal turn:

You have 7 turns remaining out of 10 total.

When one turn remains, append a second user message so the run does not reach the cap empty-handed:

This is your last turn: you must call report_helpful_ids now. Anything you have found but not reported is lost when this turn ends.

Ending the episode

The loop ends on the first of three conditions:

  • The model reports. report_helpful_ids was called; its ids argument is the answer.
  • The turn cap is hit. MAX_TURNS turns passed without a report. The episode ends without an answer; treat it as a miss in your metrics.
  • The response is incomplete. response.status == "incomplete" means generation hit max_output_tokens; end the episode rather than feeding a truncated turn back.

Reporting results

report_helpful_ids is the terminal tool: its ids argument is a ranked list, most helpful first, and your code returns it in the order SID-2 gave it. The reference implementation enforces two rules, returning an error observation instead of ending the episode when they are broken:

  • A report stands alone. A report_helpful_ids call may not be combined with other tool calls in the same turn.
  • No report on the first turn. The model must make at least one search before reporting.

Before returning the reported IDs to your application:

  • Deduplicate preserving order: each document counts once, and a ranged reference 'id#start:end' counts as its whole chunk.
  • Translate each model-facing ID back to your database ID with DocumentCache.to_data_id (toDataId in TypeScript, ToDataID in Go).
  • Drop any ID outside the cache's model-facing ID map, including an unobfuscated database ID. Only return database IDs translated from known model-facing IDs.

Hallucinated IDs are extremely rare. If more than 0.5% of reported IDs are hallucinated, this is most likely an implementation issue. Check that you are using the same cache throughout the episode and that search results expose only the model-facing IDs.

from sid import parse_rendered_model_facing_id


def reported_data_ids(cache, references):
    model_ids = [parse_rendered_model_facing_id(ref)[0] for ref in references]
    return [
        cache.to_data_id(model_id)
        for model_id in dict.fromkeys(model_ids)
        if cache.contains_model_facing_id(model_id)
    ]
import { parseRenderedModelFacingId } from "@sid-ai/sid-sdk";

function reportedDataIds(cache: DocumentCache, references: string[]): string[] {
  const modelIds = references.map(ref => parseRenderedModelFacingId(ref)[0]);
  return [...new Set(modelIds)]
    .filter(id => cache.containsModelFacingId(id))
    .map(id => cache.toDataId(id));
}
import sid "github.com/sidhq/sid-go"

func reportedDataIDs(cache *sid.DocumentCache, references []string) ([]string, error) {
	var dataIDs []string
	seen := map[string]bool{}
	for _, reference := range references {
		modelID, _, err := sid.ParseRenderedModelFacingID(reference)
		if err != nil {
			return nil, err
		}
		if seen[modelID] || !cache.ContainsModelFacingID(modelID) {
			continue
		}
		seen[modelID] = true
		dataID, err := cache.ToDataID(modelID)
		if err != nil {
			return nil, err
		}
		dataIDs = append(dataIDs, dataID)
	}
	return dataIDs, nil
}

Defaults

The reference implementation sets these defaults:

  • MAX_TURNS = 10: the maximum number of calls to the model
  • DEFAULT_LIMIT = 5: results per search when the model omits limit.
  • snippet_size must be a positive integer.
  • max_output_tokens=4096 on every request.

The SDK's own defaults, importable from sid.document_cache in Python or @sid-ai/sid-sdk in TypeScript, and exported as sid.SnippetSizeDefault and sid.MinSeenOverlapDefault in Go:

  • SNIPPET_SIZE_DEFAULT = 50: words per snippet when the model omits snippet_size.
  • MIN_SEEN_OVERLAP_DEFAULT = 100: the shortest already-seen stretch worth masking.

Content