Research preview
Agent loop




Executing a turn's tool calls
SID-2 usually makes several tool calls per turn. The reference implementation splits execution in two:
-
Fetch in parallel. The backend queries behind a turn's
search/text_searchcalls are independent, so run them concurrently (the Python reference uses aThreadPoolExecutorcapped atMAX_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
asynciothere: make each episode a coroutine, gate them with aSemaphore, 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. -
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_idswas called; itsidsargument is the answer. - The turn cap is hit.
MAX_TURNSturns 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 hitmax_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_idscall 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(toDataIdin TypeScript,ToDataIDin 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 modelDEFAULT_LIMIT = 5: results per search when the model omitslimit.snippet_sizemust be a positive integer.max_output_tokens=4096on 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 omitssnippet_size.MIN_SEEN_OVERLAP_DEFAULT = 100: the shortest already-seen stretch worth masking.