30 min

Brief, digest, budget

A budget that lives in the system prompt is not a budget.

Milestone A page worth 38,000 tokens comes back as a digest under 800, with exactly one schema repair attempt.

The runner from chapter 05 works, but it returns prose. That makes it a smaller agent, not a tiny agent - the condensing is missing.

In this chapter delegation gets its shape: the caller sends a brief, the agent returns a digest, and between them stands a budget that is not up for negotiation.

The goal

A budget that lives in the system prompt is not a budget. "Be brief" is reliably ignored by every model as soon as the content gets interesting. The compression boundary is the only reason this architecture exists at all. It has to be enforced outside the model.

Step 1: the brief

The caller hands over not the task but what they are looking for. The difference feels academic until you have built it once:

# tiny/protocol.py
from __future__ import annotations

from dataclasses import dataclass, field


@dataclass
class Brief:
    goal: str                                    # one sentence, embedded for routing
    must_return: list[str] = field(default_factory=list)   # field names that must be filled
    inputs: dict = field(default_factory=dict)   # concrete inputs (URLs, search terms)
    context_hint: str = ""                       # at most ~200 tokens, NO dialogue history
    max_output_tokens: int | None = None         # may only lower the card budget

    def as_task(self) -> str:
        lines = [f"GOAL: {self.goal}"]
        if self.must_return:
            lines.append("REQUIRED FIELDS: " + ", ".join(self.must_return))
        if self.inputs:
            lines.append("INPUTS: " + json.dumps(self.inputs, ensure_ascii=False))
        if self.context_hint:
            lines.append(f"BACKGROUND: {self.context_hint[:800]}")
        return "\n".join(lines)

must_return is the field that carries the whole thing. The caller has to say which fields they need in the end - and because they have to say it, they notice while writing it down when they do not know themselves. Ambiguity that otherwise only surfaces in the answer surfaces here.

context_hint is capped, and the cap is the point. Pipe the dialogue history through here and you have torn down the compression boundary on the way in.

Step 2: the digest

# tiny/protocol.py (continued)
@dataclass
class Digest:
    agent_id: str
    status: str                                  # ok | partial | refused | failed
    summary: str
    fields: dict = field(default_factory=dict)
    artifacts: list[dict] = field(default_factory=list)   # from chapter 07
    notes_for_caller: str = ""
    cost: dict = field(default_factory=dict)

The five status values are not decoration, they steer the caller's behaviour:

StatusMeaningCaller's reaction
okall required fields filledcarry on
partialpartly solved, fields missingfollow up or ask another agent
refusedagent considers itself not responsiblereroute, excluding this one
failedtechnical error or budget exhaustedretry or inform the user
needs_confirmationside effect needs approvalask back, then retry

refused is the most important of them. The router can be wrong, and an agent that knows its own card is the second line of defence. Without that status it would answer something instead - and a wrong answer is more expensive than a refusal.

Step 3: schema enforcement with exactly one repair attempt

A model asked to deliver JSON sometimes delivers JSON with a preamble. Or inside a code fence. Or with a field missing. The answer to that is a repair round - and exactly one:

# tiny/runner.py (extended)
import re

JSON_BLOCK = re.compile(r"\{.*\}", re.S)


def read_digest(text: str, required: list[str]) -> tuple[dict | None, list[str]]:
    """Returns (object, error list). Object is None when nothing was salvageable."""
    found = JSON_BLOCK.search(text or "")
    if not found:
        return None, ["No JSON structure found in the answer."]
    try:
        obj = json.loads(found.group(0))
    except json.JSONDecodeError as error:
        return None, [f"JSON not readable: {error}"]
    missing = [f for f in required if f not in obj or obj[f] in (None, "")]
    return obj, [f"Required field '{f}' is missing or empty." for f in missing]

And in the loop from chapter 05, at the point where the agent stops calling tools:

        calls = message.get("tool_calls") or []
        if not calls:
            obj, errors = read_digest(message.get("content", ""), card.returns_required)
            if not errors:
                return enforce_budget(card, obj, response["usage"])

            if repaired:
                # Already tried once. Twice is stubbornness, not robustness.
                return Result(status="failed", reason="schema", errors=errors)

            repaired = True
            ctx.append(
                {
                    "role": "user",
                    "content": (
                        "Your answer was not usable:\n- "
                        + "\n- ".join(errors)
                        + "\nNow output ONLY the JSON object, nothing else."
                    ),
                }
            )
            continue

Why exactly one attempt? Because the second one almost never helps. A model that still fails to produce valid JSON after an explicit error message does not have a formatting problem but a comprehension problem - and then the third round is only more expensive than the second. A failed with a reason is the more honest result here.

Step 4: enforce the budget

Now the core of the chapter. Four places where the budget takes effect, and none of them is a request to the model:

# tiny/runner.py (continued)
def enforce_budget(card, obj: dict, usage: dict) -> Result:
    char_limit = card.budget.max_output_tokens * 4   # rough: 1 token ~ 4 characters
    status = obj.get("status", "ok")
    summary = str(obj.get("summary") or obj.get("answer") or "")

    if len(summary) > char_limit:
        # HARD. Do not ask for a new summary - that costs another call and
        # returns something that can be too long again.
        summary = summary[:char_limit].rstrip() + " [truncated]"
        status = "partial"

    return Result(
        agent_id=card.id,
        status=status,
        summary=summary,
        fields={k: v for k, v in obj.items() if k in card.returns_required},
        cost={
            "tokens_in": usage.get("prompt_tokens", 0),
            "tokens_out": usage.get("completion_tokens", 0),
        },
    )

The four places in order:

  1. max_tokens in the call. The server stops generating. Prevents runaway output, but cuts mid-sentence.
  2. The scissors after validation (above). Catches what the first one let through and honestly sets partial.
  3. max_tool_calls as a loop break. Already built in chapter 05.
  4. max_wall_seconds as a time limit. With the synchronous client that is the timeout in httpx; if you build the loop asynchronously, use asyncio.wait_for.

That point 2 sets the status to partial instead of truncating silently is where this system stops lying. The caller learns that they are holding a cut-down answer - and in chapter 07 they can ask for the rest.

The milestone

Take the longest page you can find - a substantial Wikipedia article sits well above 30,000 tokens.

# scripts/06_digest_test.py
from tiny.cards import load_registry
from tiny.protocol import Brief
from tiny.runner import invoke
from tiny.tools import http_get, html_to_text

URL = "https://en.wikipedia.org/wiki/World_War_II"

raw = html_to_text(http_get(URL))
print(f"Page as text: ~{len(raw) // 4} tokens")

brief = Brief(
    goal="Determine when the war began and when it ended",
    must_return=["start", "end"],
    inputs={"urls": [URL]},
)

card = load_registry()["web-reader"]
result = invoke(card, brief.as_task())

print(f"Digest:       ~{len(result['summary']) // 4} tokens")
print(f"Status:       {result['status']}")
print(f"Fields:       {result['fields']}")
print(f"Consumed:     {result['cost']}")
Page as text: ~38412 tokens
Digest:       ~74 tokens
Status:       ok
Fields:       {'start': '1 September 1939', 'end': '2 September 1945'}
Consumed:     {'tokens_in': 39104, 'tokens_out': 118}

The state is reached when the digest sits clearly below the card budget and the required fields are filled. Note the difference between the two token numbers at the bottom: the tiny agent consumed 39,104 tokens. The caller receives 118. That is the compression boundary, and you can only see it because both numbers are printed here.

Then reproduce the failure case: set max_output_tokens in the card to 80 and run again. The status has to jump to partial and the summary has to end in [truncated]. If that happens, your budget is hard - and not merely well meant.

And then the question this chapter leaves open: the agent read 38,000 tokens and passed on 118. What about the other 37,882? It threw them away - and it did not know what the caller was actually up to. That is exactly what chapter 07 repairs.