Research preview

Formatting documents

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. A dashed box highlights Formatting documents: your search backend and episode cache render the search results and reads returned to SID-2.

Render your search results and reads as XML <doc> blocks. The SDK's DocumentCache produces this format for you. Start with the full chunks returned by your search backend, then use the SDK to create snippets, assign model-facing IDs, and mask text already shown. The examples below use sid-sdk for Python, @sid-ai/sid-sdk for TypeScript, and github.com/sidhq/sid-go for Go. They share one cache and document.

1. Create a cache for the episode

Create one DocumentCache per episode and reuse it for every search and read in that episode. It holds the document store, the ID mapping, and the record of text already shown to the model. Use lenient mode in almost all cases:

from sid import DocumentCache

cache = DocumentCache(range_mode="lenient")
import { DocumentCache } from "@sid-ai/sid-sdk";

const cache = new DocumentCache({ rangeMode: "lenient" });
import sid "github.com/sidhq/sid-go"

cache, err := sid.NewDocumentCache(sid.DocumentCacheOptions{RangeMode: sid.RangeModeLenient})
if err != nil {
	log.Fatal(err)
}

2. Prepare the backend results

For every search result, return a stable database ID, the full chunk text, and any fields you want the model to see, such as a title. Map your backend's fields to id, content, and title in this example. If your search backend returns only snippets, fetch the full chunks before caching them. Return results in relevance order and keep the full text identical whenever the same document appears again.

document = {
    "id": "support-2fa-001",
    "title": "Two-factor authentication",
    "content": (
        "Account settings are available from the menu beside your profile. "
        "You can change your name, email address and notification preferences there. "
        "To enable two-factor authentication, open Settings, choose Security, and add an authenticator app. "
        "Save the recovery codes somewhere safe. "
        "If you lose your device, use a recovery code to sign in. "
        "Contact support if you cannot access either."
    ),
}
results = [document]
const document = {
  id: "support-2fa-001",
  title: "Two-factor authentication",
  content:
    "Account settings are available from the menu beside your profile. " +
    "You can change your name, email address and notification preferences there. " +
    "To enable two-factor authentication, open Settings, choose Security, and add an authenticator app. " +
    "Save the recovery codes somewhere safe. " +
    "If you lose your device, use a recovery code to sign in. " +
    "Contact support if you cannot access either.",
};
const results = [document];
document := map[string]any{
	"id":    "support-2fa-001",
	"title": "Two-factor authentication",
	"content": "Account settings are available from the menu beside your profile. " +
		"You can change your name, email address and notification preferences there. " +
		"To enable two-factor authentication, open Settings, choose Security, and add an authenticator app. " +
		"Save the recovery codes somewhere safe. " +
		"If you lose your device, use a recovery code to sign in. " +
		"Contact support if you cannot access either.",
}
results := []map[string]any{document}

3. Render snippets and record what was shown

Copy each result, remove its database ID, and register the document with add_document (addDocument in TypeScript, AddDocument in Go). Pass the search query to apply_snippet (applySnippet, ApplySnippet), render the view as XML, then call update_seen (updateSeen, UpdateSeen) so later searches can mask text the model has already seen. Process results in backend order.

rendered = []
for result in results:
    fields = dict(result)
    data_id = fields.pop("id")
    model_id = cache.add_document(data_id, fields)
    view = cache.apply_snippet(
        data_id,
        snippet_field="content",
        query="enable two-factor authentication",
        snippet_size=20,
    )
    rendered.append(view.render_xml())
    cache.update_seen(view)

observation = "\n".join(rendered) if rendered else "No results found. Try a different query."
print(observation)
const rendered: string[] = [];
for (const result of results) {
  const { id: dataId, ...fields } = result;
  cache.addDocument(dataId, fields);
  const view = cache.applySnippet(dataId, {
    snippetField: "content",
    query: "enable two-factor authentication",
    snippetSize: 20,
  });
  rendered.push(view.renderXml());
  cache.updateSeen(view);
}

const observation = rendered.join("\n") || "No results found. Try a different query.";
console.log(observation);
var rendered []string
for _, result := range results {
	fields := maps.Clone(result)
	dataID := fields["id"].(string)
	delete(fields, "id")
	if _, err := cache.AddDocument(dataID, fields); err != nil {
		log.Fatal(err)
	}
	view, err := cache.ApplySnippet(dataID, sid.ApplySnippetOptions{
		SnippetField: "content",
		Query:        "enable two-factor authentication",
		SnippetSize:  20,
	})
	if err != nil {
		log.Fatal(err)
	}
	xml, err := view.RenderXML()
	if err != nil {
		log.Fatal(err)
	}
	rendered = append(rendered, xml)
	if err := cache.UpdateSeen(view); err != nil {
		log.Fatal(err)
	}
}

observation := strings.Join(rendered, "\n")
if observation == "" {
	observation = "No results found. Try a different query."
}
fmt.Println(observation)

4. Return the observation to the model

Use observation as the output string in the search call's function_call_output item. Each result is one <doc> element, newline-concatenated in backend order. See the agent loop for appending tool outputs to the conversation.

The SDK generates the short ID; yours will differ. Example output:

<doc id="123456#98:245" doc_length=382 title="Two-factor authentication">
... address and notification preferences there. To enable two-factor authentication, open Settings, choose Security, and add an authenticator app. Save ...
</doc>
  • id: the document's model-facing ID (see model-facing IDs), plus a #start:end range when only part of the document is shown (see ranged references below).
  • doc_length follows: the total character count of the document's text.
  • The body sits between newlines: one after the opening tag, one before </doc>.
  • Every other field of the document renders as an attribute (here title). This is why your documents must not carry private fields. The document's text itself is the body, never an attribute. Attributes with empty values are omitted; integer values are unquoted (doc_length=382), everything else is quoted with &, <, > escaped as HTML entities (quotes are left as-is); list values join with ", ".

Model-facing IDs

Your database ID will most likely be a UUID, such as 550e8400-e29b-41d4-a716-446655440000. For performance, expose only IDs that are more token-efficient than those typically used in production databases. For this reason, remove your database ID from the document before registering it in the SDK's DocumentCache. By default, the SDK generates IDs with 6 numeric characters (for example, 123456) the first time a document appears in the episode and keeps track of the mapping between model-facing IDs and database IDs. Treat these IDs as strings. When processing the final report, drop any ID outside this mapping, including an unobfuscated database ID.

print(cache.to_model_facing_id(data_id))  # the id returned by add_document
print(cache.to_data_id(model_id))        # support-2fa-001

# Adding the same database id again keeps its original id and content.
assert cache.add_document(data_id, fields) == model_id
const { id: dataId, ...fields } = document;
const modelId = cache.toModelFacingId(dataId);
console.log(modelId);
console.log(cache.toDataId(modelId)); // support-2fa-001

// Adding the same database ID again keeps its original ID and content.
console.assert(cache.addDocument(dataId, fields) === modelId);
dataID := document["id"].(string)
fields := maps.Clone(document)
delete(fields, "id")
modelID, err := cache.ToModelFacingID(dataID)
if err != nil {
	log.Fatal(err)
}
fmt.Println(modelID)
fmt.Println(cache.ToDataID(modelID)) // support-2fa-001 <nil>

// Adding the same database ID again keeps its original ID and content.
if again, _ := cache.AddDocument(dataID, fields); again != modelID {
	log.Fatal("model-facing ID changed")
}

Ranged references

When the body shows only part of the document, the ID names exactly which part: in id#start:end, the offsets are character positions into the document's text such that content[start:end] in Python is the stretch the body covers. Every SDK uses Unicode code-point offsets. In TypeScript, the equivalent is Array.from(content).slice(start, end).join(""); String.slice() uses UTF-16 offsets, which differ for characters such as emoji. In Go, it is string([]rune(content)[start:end]); slicing a string directly uses byte offsets. Use the SDK to render ranges. When the whole document is shown, the ID is bare. The model can hand any ranged reference back:

  • to read, to fetch just that span of the document;
  • to report_helpful_ids, where it counts as its whole chunk.

Use the SDK parser to split a reference into its ID and character range:

from sid import parse_rendered_model_facing_id

reference = f"{model_id}#98:245"
model_facing_id, char_range = parse_rendered_model_facing_id(reference)
print(cache.to_data_id(model_facing_id))  # support-2fa-001
print(char_range)                        # (98, 245)
import { parseRenderedModelFacingId } from "@sid-ai/sid-sdk";

const reference = `${modelId}#98:245`;
const [modelFacingId, charRange] = parseRenderedModelFacingId(reference);
console.log(cache.toDataId(modelFacingId)); // support-2fa-001
console.log(charRange);                    // [98, 245]
reference := modelID + "#98:245"
modelFacingID, charRange, err := sid.ParseRenderedModelFacingID(reference)
if err != nil {
	log.Fatal(err)
}
fmt.Println(cache.ToDataID(modelFacingID)) // support-2fa-001 <nil>
fmt.Println(*charRange)                    // [98 245]

Snippets

A search result's body is a snippet: a window of about snippet_size words (default 50) most relevant to the query. The SDK scores sliding windows of snippet_size words, with a stride of one fifth of that, by BM25 similarity to the query (separate unigram and bigram scores, over Unicode tokenization) and takes the best one; a document of snippet_size words or fewer renders whole.

Tokenization is language-aware. In TypeScript, set new DocumentCache({ language: "english" }) or pass a language option to applySnippet. In Go, set Language in DocumentCacheOptions or ApplySnippetOptions. DocumentCache(language="english") is the default; Danish, Dutch, Finnish, French, German, Hungarian, Italian, Norwegian, Portuguese, Russian, Spanish and Swedish are also available, each lowercasing and removing that language's stopwords. Use language="generic" for lowercase Unicode tokenization with no stopword removal; that is also the right setting for Chinese, Japanese and Korean, which have no dedicated entries. apply_snippet takes a one-call language= override. If every query token is a stopword, all windows score equally and the selector returns the beginning of the document. Truncation is marked with ellipses: ... before the body when the shown stretch does not start at character 0, and ... after it when it does not reach the end of the document.

Pass the query and snippet size to apply_snippet. Set display_fields (displayFields in TypeScript, DisplayFields in Go) to choose which fields are shown, including the text field:

snippet = cache.apply_snippet(
    data_id,
    snippet_field="content",
    query="recovery code",
    snippet_size=20,
    display_fields=["title", "content"],
    language="english",
)
print(snippet.render_xml())
const snippet = cache.applySnippet(dataId, {
  snippetField: "content",
  query: "recovery code",
  snippetSize: 20,
  displayFields: ["title", "content"],
  language: "english",
});
console.log(snippet.renderXml());
snippet, err := cache.ApplySnippet(dataID, sid.ApplySnippetOptions{
	SnippetField:  "content",
	Query:         "recovery code",
	SnippetSize:   20,
	DisplayFields: []string{"title", "content"},
	Language:      sid.LanguageEnglish,
})
if err != nil {
	log.Fatal(err)
}
xml, err := snippet.RenderXML()
if err != nil {
	log.Fatal(err)
}
fmt.Println(xml)

Seen markers and the seen-ledger

The rendering loop above recorded the first view. Repeat the same search:

repeated = cache.apply_snippet(
    data_id,
    snippet_field="content",
    query="enable two-factor authentication",
    snippet_size=20,
)
print(repeated.render_xml())
const repeated = cache.applySnippet(dataId, {
  snippetField: "content",
  query: "enable two-factor authentication",
  snippetSize: 20,
});
console.log(repeated.renderXml());
repeated, err := cache.ApplySnippet(dataID, sid.ApplySnippetOptions{
	SnippetField: "content",
	Query:        "enable two-factor authentication",
	SnippetSize:  20,
})
if err != nil {
	log.Fatal(err)
}
xml, err = repeated.RenderXML()
if err != nil {
	log.Fatal(err)
}
fmt.Println(xml)
<doc id="123456" doc_length=382 title="Two-factor authentication">
[seen: "#98:245"]
</doc>

To keep context lean across overlapping searches, the SDK keeps a per-document ledger of the character spans the model has already been shown. When a snippet overlaps the ledger:

  • Stretches already seen are replaced by a [seen: "#start:end"] marker instead of repeating. The offsets index the same document, so the model can re-fetch a masked stretch by passing id#start:end to read. With snippet_size=30, the same query returns:

    <doc id="123456#74:283" doc_length=382 title="Two-factor authentication">
    ... change your name, email [seen: "#98:245"] the recovery codes somewhere safe. If ...
    </doc>
  • Already-seen stretches shorter than 100 characters are re-shown rather than masked; a marker would cost more than it saves. The threshold is apply_snippet(..., min_seen_overlap=...) (minSeenOverlap in TypeScript, MinSeenOverlap in Go), defaulting to MIN_SEEN_OVERLAP_DEFAULT = 100.

  • When the entire snippet has been seen, the body is only the marker, and the ID is bare, with no range or ellipses:

    <doc id="123456" doc_length=382 title="Two-factor authentication">
    [seen: "#98:245"]
    </doc>

Call cache.update_seen(view) (cache.updateSeen(view) in TypeScript, cache.UpdateSeen(view) in Go) after rendering a view. The SDK records its displayed spans, not the masked ones, into the ledger and merges overlapping or adjacent spans. The ledger's offsets index the document text that was rendered first, which is why your backend must return the identical content string for the same document everywhere.

Rendering reads

The model asked for a read explicitly, so reads are never masked: they render in full even when already seen, and their spans are recorded in the ledger like any other view. Cap read() at 10,000 characters of document text per call to keep tool observations bounded. A bare ID returns the first 10,000 characters (or the whole chunk if shorter); a ranged reference returns at most 10,000 characters starting at the validated range's start. Count Unicode code points, matching the SDK's offsets.

Enforce this cap in your read handler before rendering the view and updating the seen ledger. Keep the full document in the cache and preserve doc_length; the rendered ID must describe only the span actually returned. The model can read the next span with a ranged reference such as id#10000:20000.

Use range_mode="lenient" in almost all cases. Use rangeMode: "lenient" in TypeScript and RangeMode: sid.RangeModeLenient in Go. Ranges are half-open Unicode code-point offsets:

  • range_mode="lenient" (the default) reduces a partially overlapping range to its exact intersection with the document. A range that is empty, inverted (start >= end), malformed, or wholly disjoint from the document raises InvalidCharacterRange.
  • range_mode="strict" requires 0 <= start < end <= len(content) exactly, and raises otherwise.

Call resolve_char_range (resolveCharRange in TypeScript, ResolveCharRange in Go) before passing a model-provided range onward, and turn InvalidCharacterRange into a recoverable tool error the model can act on rather than letting it end the episode.

This read tool accepts either a bare ID or a ranged reference, and returns model mistakes as error observations:

from sid import InvalidCharacterRange, parse_rendered_model_facing_id


def render_read(reference: str) -> str:
    try:
        model_facing_id, char_range = parse_rendered_model_facing_id(reference)
        if not cache.contains_model_facing_id(model_facing_id):
            return "Unknown document id. Use search or text_search first."

        data_id = cache.to_data_id(model_facing_id)
        if char_range is not None:
            char_range = cache.resolve_char_range(data_id, "content", char_range)
    except (ValueError, InvalidCharacterRange) as error:
        return f"Error executing read: {error}"

    if char_range is not None:
        start, end = char_range
        char_range = (start, min(end, start + 10_000))
    elif len(cache.get_document(data_id)["content"]) > 10_000:
        char_range = (0, 10_000)

    read_view = cache.get_single_span_document_view(
        data_id,
        snippet_field="content",
        snippet_display_span=char_range,
    )
    output = read_view.render_xml()
    cache.update_seen(read_view)
    return output


print(render_read(f"{model_id}#98:99999"))
import { InvalidCharacterRange } from "@sid-ai/sid-sdk";

function renderRead(reference: string): string {
  try {
    const [modelId, range] = parseRenderedModelFacingId(reference);
    if (!cache.containsModelFacingId(modelId)) {
      return "Unknown document ID. Use search or text_search first.";
    }
    const dataId = cache.toDataId(modelId);
    let span = range === null ? undefined : cache.resolveCharRange(dataId, "content", range);
    if (span) {
      span = [span[0], Math.min(span[1], span[0] + 10_000)];
    } else if (Array.from(cache.getDocument(dataId).content as string).length > 10_000) {
      span = [0, 10_000];
    }
    const readView = cache.getSingleSpanDocumentView(dataId, {
      snippetField: "content",
      snippetDisplaySpan: span,
    });
    const output = readView.renderXml();
    cache.updateSeen(readView);
    return output;
  } catch (error) {
    if (error instanceof InvalidCharacterRange || error instanceof TypeError || error instanceof RangeError) {
      return `Error executing read: ${error.message}`;
    }
    throw error;
  }
}

console.log(renderRead(`${modelId}#98:99999`));
func renderRead(cache *sid.DocumentCache, reference string) (string, error) {
	modelID, charRange, err := sid.ParseRenderedModelFacingID(reference)
	if err != nil {
		return "Error executing read: " + err.Error(), nil
	}
	if !cache.ContainsModelFacingID(modelID) {
		return "Unknown document ID. Use search or text_search first.", nil
	}
	dataID, err := cache.ToDataID(modelID)
	if err != nil {
		return "", err
	}
	if charRange != nil {
		resolved, err := cache.ResolveCharRange(dataID, "content", *charRange)
		var invalid *sid.InvalidCharacterRange
		if errors.As(err, &invalid) {
			return "Error executing read: " + err.Error(), nil
		} else if err != nil {
			return "", err
		}
		charRange = &resolved
	}

	// Cap document text per read at 10,000 code points.
	if charRange != nil {
		charRange = sid.RangeValue(charRange.Start(), min(charRange.End(), charRange.Start()+10_000))
	} else {
		document, err := cache.GetDocument(dataID)
		if err != nil {
			return "", err
		}
		if utf8.RuneCountInString(document["content"].(string)) > 10_000 {
			charRange = sid.RangeValue(0, 10_000)
		}
	}

	readView, err := cache.GetSingleSpanDocumentView(dataID, sid.SingleSpanOptions{
		SnippetField:       "content",
		SnippetDisplaySpan: charRange,
	})
	if err != nil {
		return "", err
	}
	output, err := readView.RenderXML()
	if err != nil {
		return "", err
	}
	return output, cache.UpdateSeen(readView)
}

output, err := renderRead(cache, modelID+"#98:99999")
if err != nil {
	log.Fatal(err)
}
fmt.Println(output)

The SDK trims the range to the end of this 382-character document. The read includes the text even though part of it has already been shown:

<doc id="123456#98:382" doc_length=382 title="Two-factor authentication">
... address and notification preferences there. To enable two-factor authentication, open Settings, choose Security, and add an authenticator app. Save the recovery codes somewhere safe. If you lose your device, use a recovery code to sign in. Contact support if you cannot access either.
</doc>

Only allow reads for IDs already returned in the episode. Answer a read for an unknown ID with an error observation telling the model to search first.

Content