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:
| Status | Meaning | Caller's reaction |
|---|---|---|
ok | all required fields filled | carry on |
partial | partly solved, fields missing | follow up or ask another agent |
refused | agent considers itself not responsible | reroute, excluding this one |
failed | technical error or budget exhausted | retry or inform the user |
needs_confirmation | side effect needs approval | ask 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:
max_tokensin the call. The server stops generating. Prevents runaway output, but cuts mid-sentence.- The scissors after validation (above). Catches what the first one let
through and honestly sets
partial. max_tool_callsas a loop break. Already built in chapter 05.max_wall_secondsas a time limit. With the synchronous client that is thetimeoutinhttpx; if you build the loop asynchronously, useasyncio.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.