Context management

Bound what reaches the model each turn. A summary is a durable step, so a replay reads what was written instead of writing it again.

step.ai.loop's history array grows by one entry every turn and nothing trims it. An agent that runs long enough eventually hits the model's context window and fails - the only failure mode of its kind, since every other bound the loop enforces (maxIterations, maxApprovals) is a ceiling you set on purpose.

A context trimmer is that bound, as a port. Duraton ships three adapters - count, token-budget and summarize - and the port is what lets a later one drop in without touching the loop or the adapters already there.

Context management is a TypeScript-SDK feature today. The Python and Go SDKs do not expose it yet - see SDK parity.

Turning it on

import { getContextTrimmer } from "@duraton/sdk";

const result = await ctx.step.ai.loop("agent", {
  prompt: `Resolve this ticket: ${ticket}`,
  maxIterations: 20,
  context: getContextTrimmer({ strategy: "count", keepLast: 10 }),
  tools: { "search-policy": searchPolicy, "issue-refund": issueRefund },
  turn,
});

With @duraton/agent-kit it is a compact descriptor on agent() instead - see Bounding context for that surface.

Omitted, an agent behaves exactly as it did before this option existed: the whole history reaches every turn.

The three strategies

StrategyWhat it doesCalls a model
countDrops the oldest iterations beyond keepLastNo
token-budgetSame drop, sized by an estimated token count (maxTokens) instead of a countNo
summarizeOverwrites the earliest surviving iteration's own result with a summary of everything older than keepLast, and drops the restYes

count and token-budget never remove anything the run itself remembers - each is a fresh view computed before every turn, so the loop's own history (what a replay reads, what a later stop predicate sees) still has every iteration. Recomputing costs nothing, so there is nothing to gain by remembering the last result.

summarize is different: it pays for its own output; the loop's own history is replaced by the result, so a later turn starts from the already-reduced baseline instead of paying to re-summarize the same growing prefix on every turn.

context: getContextTrimmer({
  strategy: "summarize",
  maxTokens: 8000,
  keepLast: 4,
  model: "claude-haiku-4-5", // optional - a cheaper model than the agent's own is a common choice
  generate: async (prompt) => (await provider.generate({ model, prompt })).text,
}),

@duraton/agent-kit's agent() builds generate for you from the same provider/model/apiKey every turn already uses - the raw SDK option above takes it directly because step.ai.loop has no provider of its own to resolve one from.

How a summary is represented

A summary does not add a new kind of history entry - LoopIteration stays exactly { toolCalls, toolResults }, the same shape it has always been. Instead, the earliest surviving iteration's own toolResults[].output is overwritten with the summary text, reusing that entry's real id and name rather than fabricating one:

// Before: three real tool calls
[
  { "toolCalls": [{ "id": "c1", "name": "search-policy", "input": { "q": "refund window" } }], "toolResults": [{ "id": "c1", "output": { "days": 30 } }] },
  { "toolCalls": [{ "id": "c2", "name": "search-policy", "input": { "q": "exceptions" } }], "toolResults": [{ "id": "c2", "output": { "none": true } }] },
  { "toolCalls": [{ "id": "c3", "name": "issue-refund", "input": { "amount": 40 } }], "toolResults": [{ "id": "c3", "output": { "refunded": true } }] }
]

// After summarizing with keepLast: 1 - "c1" is reused, "c2" is gone, "c3" (the kept tail) is untouched
[
  { "toolCalls": [{ "id": "c1", "name": "search-policy", "input": { "q": "refund window" } }], "toolResults": [{ "id": "c1", "output": "Checked refund policy (30-day window, no exceptions found)." }] },
  { "toolCalls": [{ "id": "c3", "name": "issue-refund", "input": { "amount": 40 } }], "toolResults": [{ "id": "c3", "output": { "refunded": true } }] }
]

This mirrors Anthropic's own clear_tool_uses context-editing behaviour: an existing entry's content is cleared or replaced in place, never a new one invented. A second summarization folds the same way - the reused entry's current content (raw or already a summary) feeds the next summarization call, so history never carries more than one summary-bearing entry at once.

The summary is a durable step

Every context-trim pass - count and token-budget included, not only summarize - writes its own step, <base>:iter:N:context, a sibling of the turn step and the guardrail step, never a suffix of either. Two consequences follow from that and from nothing else:

  • A replay reads the recorded result. summarize's model call does not run again, so a re-run of the same run cannot disagree with the original, and the summary is an auditable fact rather than something re-derived on every pass.
  • Every adapter gets the same guarantee, whether or not it happens to call a model. count and token-budget are pure functions that would be replay-safe either way; wrapping them identically means adding a future model-calling adapter never needs new plumbing.

A loop with no context option writes no context-trim steps, so turning this on costs nothing until you do.

Writing a trimmer

A trimmer takes the accumulated history and the prompt, and returns a (possibly unchanged) result. It never mutates the caller's history array - it says what the new view should be and the loop applies it.

import type { ContextTrimmer } from "@duraton/sdk";

const keepRecentSearches: ContextTrimmer = {
  name: "count",
  trim: ({ history }) => {
    const keepLast = 5;
    if (history.length <= keepLast) return { history: [...history], trimmed: false, persist: false };
    return { history: history.slice(-keepLast), trimmed: true, persist: false };
  },
};

persist is what separates a cheap per-turn view (false - recomputed every time, the loop's own record is untouched) from a result that should become the new baseline (true - the loop replaces its own accumulator, so a later turn's trim pass sees the reduced history rather than the original). Only set it true when the work being saved by not recomputing is worth the loop's history no longer holding what was replaced.

On this page