The Tool Protocol and the Read-Only Trio: Schema Design, Chunk Merging, and Result Truncation
Give the agent eyes to read code: write three read-only tools — read file, find file, search content — and nail down the tool schema, merging streamed tool-call chunks, and how to truncate an overly long result once and for all; the next eighteen days rely on it.
Today's Goals
- Write a description and JSON Schema for a tool that a model can genuinely use correctly, and explain what belongs in the description and what doesn't
- Correctly merge tool-call chunks that arrive via streaming, and explain why you can't assume the index starts at zero
- Design a result-truncation strategy that keeps key information without blowing up the context
Today is the course's turning point: the Agent decides for the first time what to look at next. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
Read access first: browse the code, do not touch it
What do you give a new hire on day three? Read access to the repository, probably: browse code, search functions, read tests, but no commits yet. Nearly every team does this, for a plain reason — reads are reversible, writes are not. Read the wrong file and you waste five minutes; edit the wrong file and you may cause a production incident.
An Agent is identical, and needs the step even more, because reading starts from zero for it. For two days it had no tools, so asking "why is this repository's test red" left it guessing — it could not see your files. With three tools it walks a path of its own: list the files, search a suspicious function name, read that implementation, then conclude.
The day deserves its own slot because the tool layer's interface is fixed today, and every tool of the next eighteen days (editing files, running commands, connecting MCP, loading Skills) takes the same shape. Get it wrong today and you pay interest daily. Three things need fixing: how a tool is described to the model, how the model's call request is caught, and how a tool's result is sent back. Each has a trap, matching today's three sections.
The messages array (the whole thing gets resent every round)
In that diagram we completed only the "model produces text" half yesterday. Today fills in the other half: the model requests a tool, we execute it, append the result as a new message, and send again — until it stops asking. That closed loop is the whole of an Agent; the other twenty days add constraints to it.
Three parts of the tool protocol: name, description, parameter schema
When a tool is presented to a model, only three things are visible: the name, a natural-language description, and a JSON Schema for the parameters. It knows nothing of the implementation.
That has a direct corollary people get backwards on their first tool: the description is written for the model, not for the maintainer. So it should say when to use the tool, how to fill the parameters and what the limits are, not that "this function reads via a stream internally."
export const globTool: ToolDef = {
name: 'glob',
// Three things in a description: what it is for, what the syntax looks like, what the limits are
description:
'List files in the working directory by wildcard, returning relative paths. ' +
'Supports ** across directories, * for any characters at one level, ? for one character, ' +
'for example **/*.js or test/**/*.test.js. Skips node_modules and .git automatically.',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'wildcard, e.g. **/*.js; omitted means **/*' },
limit: { type: 'integer', description: 'maximum entries returned, default 200' },
},
required: ['pattern'],
additionalProperties: false,
},
readOnly: true, // D5's approval gate uses this field to decide whether to ask first
async run(args: unknown, ctx: ToolContext): Promise<ToolResult> {
// The model cannot see the implementation, so nothing here affects whether it calls correctly
},
}GLOB_TOOL = ToolDef(
name="glob",
# Three things in a description: what it is for, the syntax, the limits
description=(
"List files in the working directory by wildcard, returning relative paths. "
"Supports ** across directories, * for any characters at one level, ? for one "
"character, for example **/*.js or test/**/*.test.js. Skips node_modules and .git."
),
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "wildcard, e.g. **/*.js; omitted means **/*"},
"limit": {"type": "integer", "description": "maximum entries returned, default 200"},
},
"required": ["pattern"],
"additionalProperties": False,
},
read_only=True, # D5's approval gate uses this to decide whether to ask first
run=run_glob,
)Four rules of thumb, all directly copyable:
- Name it verb plus noun: short, lowercase, underscore-separated. Models understand
read_filefar more reliably thanfileReader, because tool names in training data mostly look like that. - Put examples in the description. A model's memory of wildcard and regex syntax is less reliable than you think, and one
**/*.jsexample saves two rounds of trial and error. - Give every parameter a description, including its default. The words "default 200" make the model simply omit the parameter when it does not need a limit.
- Do not stuff two jobs into one tool. A "read a file or list a directory depending on whether path is a file" design gets used wrongly half the time. Two tools are better.
One field is for us rather than the model: readOnly. All three of today's tools are read-only, so it looks useless; day five's approval gate uses it to distinguish "pass straight through" from "ask first." Fixing the field today is far cheaper than adding it to every tool on day five.
The real shape of a streamed tool call: only valid JSON once joined
Now look at the model's call request. This is where documentation misleads beginners most — the documented example is the complete non-streaming structure, while what you receive in streaming is chopped up.
One stream carrying a tool call, with the outer wrapper removed, measured on 7 September 2026 (one call split into six to twelve fragments), looks roughly like this:
{"index":1,"id":"call_abc","function":{"name":"glob","arguments":""}}
{"index":1,"function":{"arguments":"{\"pat"}}
{"index":1,"function":{"arguments":"tern\":\"**/"}}
{"index":1,"function":{"arguments":"*.js\"}"}}Four regularities, each of which bites:
- The id and the function name appear only in the first fragment, and later fragments carry only argument increments. So merging must not overwrite; write only when a value is present.
- The arguments are a chopped-up JSON string that is valid only once joined. Which is why yesterday's type has
ToolCall.argsas a string, not an object — parsing must happen after joining. - Split points are arbitrary, landing inside a quote or inside an escape sequence. Any "try parsing on every fragment" approach yields a run of failures.
- The finish reason is a dedicated value (tool calls rather than a normal stop). But do not trust that field alone: we measured models reporting a normal stop while still emitting tool fragments, and reading only the field treats that turn as finished. The safe criterion is "the finish reason is tool calls, or at least one call was merged."
The index does not start at zero: a trap we measured
Merging depends entirely on a fragment's index. Which produces the error everyone makes once: assuming the index starts at 0 and storing by array subscript.
That is not a theoretical risk. On 7 September 2026, on the same gateway (https://api.n1n.ai/v1), with the same model (claude-haiku-4-5-20251001) and the same code, two runs of a parallel tool call returned these indexes:
first run: index = 1, index = 2
second run: index = 0, index = 1Same day, same gateway, same model, same code, two starting points. So the correct conclusion is not "vendor X starts at 1" but that the starting point cannot be assumed — we merely measured two of them. It is an opaque identifier whose only dependable property is that fragments of one call share one index.
Pushing in arrival order is worse, because parallel calls' fragments interleave: the second call's head may arrive before the first call's head, and then their argument fragments alternate. Joining in arrival order stitches two calls' arguments into one.
export class ToolCallAccumulator {
/** A Map rather than an array: the index may start anywhere and may not be contiguous */
private readonly byIndex = new Map<number, Partial>()
push(delta: { index: number; id?: string; name?: string; argsDelta?: string }): void {
const slot = this.byIndex.get(delta.index) ?? { index: delta.index, args: '' }
if (delta.id) slot.id = delta.id // only in the first fragment, so write only when present
if (delta.name) slot.name = delta.name
if (delta.argsDelta) slot.args += delta.argsDelta // arguments accumulate, never overwrite
this.byIndex.set(delta.index, slot)
}
/** Drain in index order, not arrival order: parallel calls' fragments interleave */
drain(): ToolCall[] {
return [...this.byIndex.values()]
.sort((a, b) => a.index - b.index)
.map((slot, position) => ({
id: slot.id ?? `call_${slot.index}_${position}`, // give one even if the id is missing
name: slot.name ?? '',
args: slot.args,
}))
}
}from dataclasses import dataclass, field
@dataclass
class ToolCallAccumulator:
# A dict rather than a list: the index may start anywhere and may not be contiguous
by_index: dict[int, dict[str, str]] = field(default_factory=dict)
def push(self, delta: ToolCallDelta) -> None:
slot = self.by_index.setdefault(delta.index, {"args": ""})
if delta.id:
slot["id"] = delta.id # only in the first fragment, so write when present
if delta.name:
slot["name"] = delta.name
if delta.args_delta:
slot["args"] += delta.args_delta # arguments accumulate, never overwrite
def drain(self) -> list[ToolCall]:
# Drain in index order, not arrival order: parallel fragments interleave
return [
ToolCall(
id=slot.get("id") or f"call_{index}", # give one even if the id is missing
name=slot.get("name", ""),
args=slot["args"],
)
for index, slot in sorted(self.by_index.items())
]Argument parsing failures are normal: feed them back, do not throw them at the user
Once joined, parse. Here comes a shift in mindset, the biggest difference between a Coding Agent and an ordinary backend service:
A parse failure is not an exception, it is a normal return value.
An ordinary service that receives an illegal parameter throws a 400 and is done — the caller is a program and will fix it next time. An Agent's caller is a model, writing by guesswork: it omits required parameters, writes numbers as strings, invents tool names. The correct handling is to hand the error back verbatim and let it fix itself — just as, when a new hire submits code that will not run, pasting the error to them beats fixing it for them.
So the tool execution entry point has a hard rule: always return a result, never throw. Four situations all translate into a failed result:
| Situation | What is fed back to the model |
|---|---|
| The tool name does not exist (the model invented it) | there is no such tool, plus the list of available ones |
| The arguments are not valid JSON | that parsing failed, plus the leading part of what it sent |
| A required parameter is missing | exactly which ones are missing |
| The tool itself threw | the exception message (not the stack) |
The fed-back text must be specific enough to act on. "Operation failed" is useless; "there is no tool named edit_file. Available tools are: glob, grep, read_file" is useful — the model reads it and takes another route. The lab shows this directly: have the offline script invent an editing tool that does not exist yet, and the terminal reads:
> edit_file({"path":"src/calc.js","old":"a / b"})
x edit_file fed back 45 chars - there is no tool named edit_file. Available: glob, grep, read_file
So there is no editing tool today, only three read-only ones. Let me read the code first.
> read_file({"path":"src/calc.js"})
v read_file fed back 248 chars - src/calc.js (lines 1-13 of 13)Two invariants remain; miss either and the next request is invalid, and most gateways reject it outright: the assistant message must carry the calls it requested, and every call must have exactly one corresponding result message — even a failed one. Day six's error classification returns here; today, just establish the never-throw discipline.
Each read-only tool's boundary
Three tools, each with one place that causes trouble if left unbounded.
read_file: how much to read. Default to four hundred lines, not the whole file. Stuffing a whole file into context is the most common waste, and the model usually knows where to look from the opening. Two more details: output line numbers (the model locates by them, and tomorrow's editing tool talks in them too), and truncate any single line over five hundred characters (one line of minified JS can run to hundreds of thousands). On a binary file, say so explicitly instead of feeding garbage back.
glob: what to exclude. Skip directories like node_modules, .git and dist while walking, or one call returns ten thousand files, all noise. Cap results at two hundred and tell the model how many were withheld — otherwise it believes it saw everything and reasons on a false premise. An empty result should also state how many files the working directory holds in total, helping it tell "nothing there" from "I wrote the pattern wrong."
grep: bound all three dimensions. Match count (default fifty), line length (one hundred and twenty characters), and file scope (an optional wildcard parameter; narrowing first is far cheaper than filtering afterwards). Fix the output format as path, colon, line number, colon, content, because that is the shape the model has seen most and it can call read_file from it directly.
All three share one boundary: every path must stay inside the working directory. The model receives relative paths and will try to climb out — not out of malice, but because it is guessing. The only valid criterion is whether the resolved absolute path is still inside; scanning the string for two dots is unreliable. Remember to append a separator to the root when comparing, or a sibling directory with the same prefix is wrongly judged inside.
In the lab, asking "look at this repository and tell me why the tests are red" walks the offline script through glob, grep and read_file, feeding back 50, 361 and 248 characters respectively — three reproducible numbers, because the sandbox repository is generated deterministically.
Result truncation: cut from the middle, keep both ends, and say how much was cut
The last thing, and the most overlooked: tool results are fed back into the message array, and the whole message array is resent every turn.
That sentence needs arithmetic to feel. A grep matching two thousand lines, roughly sixty thousand characters, is paid for as input tokens not just this turn but every turn after; five turns is five payments. Worse, it squeezes out the context that matters — the user's request, the key code read earlier.
So set a ceiling for a single tool result. This course fixes it at eight thousand characters, with three rules:
export const MAX_RESULT_CHARS = 8000
const HEAD_RATIO = 0.6 // more head: the most relevant content is usually at the front
export function truncateResult(result: ToolResult): ToolResult {
const text = result.content
if (text.length <= MAX_RESULT_CHARS) return result
const head = Math.floor(MAX_RESULT_CHARS * HEAD_RATIO)
const removed = text.length - MAX_RESULT_CHARS
// The middle note is for the model: it cannot read an ellipsis but can read an instruction
const note = `\n...(${removed} characters truncated from the middle; ${text.length} characters in total; read again with an offset for the middle)...\n`
return {
...result,
content: text.slice(0, head) + note + text.slice(text.length - (MAX_RESULT_CHARS - head)),
// Truncation itself is for humans and need not spend the model's tokens: meta is not fed back
meta: { ...result.meta, truncated: true, originalChars: text.length },
}
}MAX_RESULT_CHARS = 8000
HEAD_RATIO = 0.6 # more head: the most relevant content is usually at the front
def truncate_result(result: ToolResult) -> ToolResult:
text = result.content
if len(text) <= MAX_RESULT_CHARS:
return result
head = int(MAX_RESULT_CHARS * HEAD_RATIO)
removed = len(text) - MAX_RESULT_CHARS
# The middle note is for the model: it cannot read an ellipsis but can read an instruction
note = (
f"\n...({removed} characters truncated from the middle; {len(text)} in total; "
"read again with an offset for the middle)...\n"
)
return replace(
result,
content=text[:head] + note + text[-(MAX_RESULT_CHARS - head) :],
# Truncation is for humans and need not spend the model's tokens: meta is not fed back
meta={**(result.meta or {}), "truncated": True, "originalChars": len(text)},
)The three rules, explained. One: cut from the middle, never keep only the head — the tail often holds the conclusion: the last line of an error, a test summary line, a file's export list. Keeping only the head throws the conclusion away, and it is the most common wrong approach. Two: state how much was cut and how to fetch the middle next; the model cannot read an ellipsis but can read an instruction. Three: put the fact of truncation in meta, for terminal rendering only, not fed back to the model.
One design choice too: truncation lives at the tool execution entry point, not inside each tool. Tool authors only get the content right, and the context budget is managed in one place — otherwise, twenty days later, fifteen tools each carry their own truncation logic.
The lab's self-test shows two reproducible numbers: a twenty-thousand-character fake result truncated to 8054 characters (the eight-thousand ceiling plus the note), stating that 12000 characters were cut; and having the model read a generated file of over thirty-eight thousand characters, which likewise feeds back 8054. How the total context budget is split between the system prompt, the history and tool results is day twelve's subject — today handles one thing only: no single result may be unbounded.
Source Reading
Hands-On Lab
Today's lab spins the loop for real for the first time. The starter leaves three exercises, matching the three easiest mistakes in this chapter: fragment merging, illegal-argument feedback, and result truncation. Unmodified it passes two of seven.
- Define the tool registry and one unified execution entry point, so the loop knows only "list" and "execute" and no concrete tool.
- Implement the three read-only tools, each with parameter validation and limits; share one path-boundary check across all three and confirm climbing out of the directory is blocked.
- Merge tool-call fragments into complete calls by index, feed illegal arguments with
INJECT=bad_args, and confirm it becomes a failed result fed back rather than a thrown stack. - Add truncation to tool results: over eight thousand characters, cut from the middle, keep both ends and state how much was cut.
- Run the self-test:
MOCK=1 SELFTEST=1 pnpm startshould print 7/7 passed, with the call order and the truncated character count both reproducible numbers.
Acceptance is five ticks: the self-test prints 7/7 passed; one question shows glob, grep and read_file cards in order followed by a conclusion; interleaved fragments starting at index 1 merge into two calls; a twenty-thousand-character result truncates to 8054 characters stating 12000 were cut; and INJECT=bad_args lets the loop finish with exit code 0.
Interview Questions
Today's three questions are all of the "you can answer only if you implemented it" kind:
- How should a tool's description and JSON Schema be written so the model makes fewer mistakes?
- How do you merge tool-call arguments arriving via streaming? What assumptions are forbidden?
- When a tool result far exceeds the context budget, what is your truncation strategy? How do you avoid cutting the key information?
Full bilingual prompts, analyses and key points are in this course's day-three question bank. Question two discriminates most in this course — anyone who has not merged fragments themselves cannot answer "what you must not assume," which is exactly the half-sentence the interviewer wants.
Checklist and Tomorrow
- I can name the three things the model can see, and what does and does not belong in a description
- I can explain why the arguments are a string rather than an object, and when parsing should happen
- I can explain why the index's starting point cannot be assumed, and what is wrong with joining in arrival order
- I can list the four situations translated into failed results, plus the two message invariants
- I can name each read-only tool's bounded dimension and state the path-boundary criterion
- The self-test prints 7/7 passed, and I know where the number 8054 comes from
Tomorrow is D4, "File Editing and Shell Execution: Exact Replacement, Conflict Detection, and Killable Child Processes on Timeout," where read access becomes write access. The risk changes by an order of magnitude, so tomorrow's focus is not how to put characters into a file but three failure modes: the old content does not match, the file changed externally, and a command hangs without returning. The order is deliberate — with reading solid today, the model can confirm that what it is about to change really looks the way it thinks; and by the end of tomorrow it will turn the sandbox repository's failing test green for the first time.
Interview questions
How do you write a tool's description and JSON Schema so the model gets it right more often?工具的描述与 JSON Schema 该怎么写,才能让模型少犯错?
Common in ChinaCommon overseasBasic#tool-design#json-schemaHow to reason about it · think before answering
- It looks like a giveaway, but answering write it clearly scores nothing. The signal is knowing what the model actually sees: only the name, the description, and the parameter schema. Implementation is invisible to it, so this is an interface design question, not a writing question.
- How to break it down: derive the rules from the mistakes. The model picks the wrong tool when the description never says when to use it; it writes bad syntax when there is no example; it passes junk parameters when defaults are unstated and extra properties are allowed; it misuses one tool half the time when that tool does two jobs.
- Conclusion as four rules: name it verb plus noun in lower snake case; describe when to use it, how to fill the parameters, and what the limits are, with one concrete example; document every parameter including its default; keep one tool to one job and split rather than branch.
- Add the point most people miss: the schema also carries fields for yourself. A read-only flag is invisible to the model but is what an approval gate uses to decide whether to pause. Define such fields in the first version, because retrofitting fifteen tools later is far more expensive.
- Likely follow-ups: how many tools? Selection accuracy degrades past a couple dozen, and the fix is grouping and on-demand loading rather than longer descriptions. How long should a description be? Delete a sentence and ask whether the model would now misuse the tool; if not, delete it, because every description is resent in the prompt on every turn.
分析过程 · 先想清楚再作答
- 这题看着像送分题,但答「写清楚一点」就没分了。区分度在于你知不知道模型看得见什么——只有名字、描述、参数 schema 三样,实现细节它一无所知。所以这是一道接口设计题,不是文档写作题。
- 怎么拆:把「模型会怎么犯错」倒推成「描述里该写什么」。它会用错工具(描述没说清什么时候用它)、会写错语法(没给例子)、会传多余参数(没写默认值,也没关掉额外属性)、会一半时间用错同一个工具(这个工具承担了两件事)。四种错误各对应一条写法。
- 结论落成四条可执行的规则:名字用动词加名词、全小写下划线分隔;描述写「什么时候用、参数怎么填、有什么限制」,并给一个真实例子;每个参数都写说明并把默认值写进去;一个工具只做一件事,宁可拆成两个。
- 再补一条别人常漏的:schema 里要有给自己看的字段。比如一个「是不是只读」的标记,模型看不见,但审批门要靠它区分「直接放行」还是「先问一句」。这类字段要在第一版就定下来,等有了十五个工具再回来补一遍,成本高得多。
- 可预期的追问一:工具该有多少个?超过二三十个之后模型的选择准确率会掉,处理办法是分组按需加载(渐进披露),而不是把描述写得更长。追问二:描述该多长?判据是「删掉这句话,模型会不会用错」——不会就删掉,因为每个工具的描述都占系统提示的预算,每一轮都要重发。
Key points
- The model sees only the name, the description, and the parameter schema, so this is interface design
- Verb-plus-noun lower snake case names; descriptions cover when to use it, how to fill parameters, and limits, with an example
- Document every parameter and its default, disallow extra properties, and keep one tool to one job
- The schema also carries self-facing fields such as a read-only flag for the approval gate, defined in version one
- Group and lazily load tools when there are many; test description length by whether deleting a line causes misuse
答题要点
- 模型只看见名字、描述、参数 schema 三样,所以这是接口设计问题
- 名字用动词加名词、小写下划线;描述写「什么时候用、参数怎么填、有什么限制」并给例子
- 每个参数写说明与默认值,关掉额外属性;一个工具只做一件事
- schema 里还要有给自己看的字段,例如只读标记,供审批门使用,第一版就定下来
- 工具太多要分组按需加载,描述长度的判据是「删掉它模型会不会用错」
How do you reassemble streamed tool-call arguments, and which assumptions are off-limits?流式返回的工具调用参数怎么归并?有哪些假设是不能做的?
Common in ChinaCommon overseasDeep dive#tool-calling#streamingHow to reason about it · think before answering
- This is one of the most discriminating questions in the course: people who have done it answer in two sentences, and people who have not can only say concatenate the arguments. The signal is the second half, the list of forbidden assumptions.
- How to break it down: describe the real shape first. The first delta carries the call id and function name with empty arguments; later deltas carry argument fragments only; one call was measured to arrive in six to twelve pieces; the stream ends with a tool-calls finish reason. So merging means finding the slot by index, writing id and name only when present, and appending arguments.
- Then the forbidden assumptions, each tied to a real incident. You cannot assume the index starts at zero: on 2026-09-07 the same model on the same gateway produced starts of 1 and 2 in one run and 0 and 1 in another, so use a dictionary rather than array positions. You cannot merge by arrival order, because parallel calls interleave and you would stitch two argument strings together. You cannot JSON-parse each fragment, because splits land inside quotes and escapes. And you cannot trust the finish reason alone, since a model was observed reporting stop while still emitting tool deltas.
- Conclusion: keep a dictionary keyed by index, append argument text, drain sorted by index, parse only once everything has arrived, and treat the turn as a tool turn if either the finish reason says so or at least one call was merged.
- One more engineering point: the id can be missing. Generate a stable one, because every tool result message must point back to a call, and a missing pairing makes the next request invalid.
- Likely follow-up: how do you test it? The bug shows up maybe half the time against a real gateway, so the offline script should deliberately start indexes at 1, chop arguments finely, and interleave two calls. Turning an intermittent failure into a certain one is the only reliable way to test protocol code.
分析过程 · 先想清楚再作答
- 这题几乎是本课最有区分度的一道:自己归并过的人两句话说清,没写过的人只能答「把参数拼起来」。题眼在后半句——「不能做的假设」,那是踩过坑才有的清单。
- 怎么拆:先描述真实形状。第一片带调用 id 与函数名、参数是空串;后面每片只带一段参数文本;一次调用实测能切成六到十二片;结束时给一个「要调工具」的结束原因。所以归并的动作是:按序号找到槽位,id 与名字「有值才写」,参数累加。
- 接着列不能做的假设,每一条都有对应的事故:一,不能假设序号从 0 开始——2026 年 9 月 7 日实测同一个模型在同一家网关的两次运行分别给出 1 和 2、以及 0 和 1 两种起点,所以要用字典而不是数组下标;二,不能按到达顺序拼,因为并行调用的分片是交错到达的,按顺序拼会把两个调用的参数缝成一个;三,不能拿到一片就试着解析 JSON,切分点可能在引号或转义符中间;四,不能只信结束原因这一个字段,实测有模型报「正常停止」却仍然给了工具分片。
- 结论:归并的正确形状是「按序号建字典、累加参数、按序号升序取出」,解析放在全部分片到齐之后,判据用「结束原因是工具调用,或者归并出了至少一个调用」的并集。
- 工程视角补一条:id 也可能缺。缺了要自己造一个稳定的标识,因为工具结果消息必须能指回某个调用,少一条对应关系,下一轮请求就不合法。
- 可预期的追问:怎么测这个逻辑?真实网关上这个 bug 有一半概率不出现,所以要在离线剧本里故意让序号从 1 开始、把参数切得很碎、并让两个调用交错到达。把偶发变成必然,是这类协议代码唯一可靠的测法。
Key points
- Real shape: the first delta carries id and name, later deltas carry argument fragments, six to twelve pieces per call in practice
- Merge by keeping a dictionary keyed by index, writing id and name only when present, appending arguments, and draining sorted by index
- Four forbidden assumptions: zero-based indexes, arrival-order merging, parsing each fragment, and trusting the finish reason alone
- Ids can be missing, so synthesize a stable one or the tool result cannot point back and the next request is invalid
- Test it by making the offline script start at index 1, split arguments finely, and interleave two calls
答题要点
- 真实形状:第一片带 id 与函数名,后面每片只带参数增量,一次调用实测六到十二片
- 归并动作:按序号建字典、id 与名字有值才写、参数累加、按序号升序取出
- 四个不能做的假设:序号从 0 起、按到达顺序拼、每片都解析一次、只信结束原因字段
- id 可能缺,要自己造一个稳定标识,否则工具结果指不回调用,下一轮请求不合法
- 测法是在离线剧本里让序号从 1 起、参数切碎、两个调用交错,把偶发变必然
When a tool result blows past your context budget, what is your truncation strategy, and how do you avoid cutting the part that matters?工具结果远超上下文预算时,你的截断策略是什么?怎么保证不截掉关键信息?
Common in ChinaCommon overseasIntermediate#context-budget#tool-designHow to reason about it · think before answering
- This checks whether you did the arithmetic. Answering cut it to some length misses the shape of the problem: tool results are fed back into the message array, and the whole array is resent every turn, so one oversized result costs once per remaining turn, not once.
- How to break it down: price it, then design. A search hitting two thousand lines is roughly sixty thousand characters, billed again on every subsequent turn, and worse, it crowds out what matters — the user's request and the code you already read. Hence a hard per-result cap.
- Then how to cut. Head-only is the common mistake, because the tail usually holds the conclusion: the last line of a stack trace, a test summary, the export list at the end of a file. So cut from the middle and keep both ends, weighting the head slightly since the most relevant content tends to come first.
- Third, say that you cut. Insert a line stating how many characters were removed, the full length, and what to do to get the middle, such as reading again with an offset. The model cannot interpret an ellipsis but can follow an instruction, and this line is the most commonly omitted yet most effective part of the strategy.
- Also decide where truncation lives: in the single tool-execution entry point, not in each tool. Tool authors get the content right and one place owns the budget, otherwise twenty tools grow twenty different truncation rules. Keep the truncated flag in render-only metadata rather than feeding it back, which saves tokens too.
- Likely follow-ups: is there something better? Yes — give tools real pagination with offset and count and document it in the description; truncation is the last line of defense. And how is the overall budget split? That belongs to the compaction layer; this layer only guarantees no single result is unbounded.
分析过程 · 先想清楚再作答
- 这题在考「有没有算过账」。答「截到一定长度」的人没意识到问题的真实形状:工具结果要回灌进消息数组,而消息数组每一轮都整体重发一次——所以一次超长结果的成本不是一次,是剩下所有轮次乘以一次。
- 怎么拆:先算清代价,再定策略。一次命中两千行的搜索大约六万字符,转五圈就付五次;更糟的是它挤掉了真正重要的上下文——用户的需求、之前读到的关键代码。于是结论很自然:单个结果必须有硬上限。
- 然后是「怎么截」。只留头是最常见的错法,因为尾部往往有结论性的信息:报错的最后一行、测试的汇总行、文件末尾的导出清单。所以从中间截、头尾都留,头可以多分一点,因为最相关的内容通常在前面。
- 第三步是「截了要说」。中间必须插一行说明:截掉了多少字符、全文多少字符、想看中间那段该怎么做(带偏移量再读一次)。模型看不懂省略号,但看得懂一条指令。这一行是策略里最容易被漏掉、却最有效的部分。
- 结论加一条位置判断:截断放在工具执行的统一入口,不放在每个工具里。工具作者只管把内容做对,预算由一处统一管——不然二十个工具会有二十份截断逻辑,且各不相同。另外「已被截断」这个标记只放在给渲染看的元数据里,不回灌给模型,省下的也是 token。
- 可预期的追问一:更好的做法有没有?有——让工具自己支持分页(偏移量与条数),并在描述里告诉模型怎么用,比事后截断优雅得多,截断是最后一道保险。追问二:整体预算怎么分?那是压缩那一层的题目,本层只保证单个结果不无限大。
Key points
- Price it first: results are resent every turn, so one oversized result costs once per remaining turn
- Cut from the middle keeping both ends with a heavier head; head-only loses conclusions like final error lines and test summaries
- Insert a note with how much was removed, the total length, and how to fetch the middle, since the model follows instructions rather than ellipses
- Truncate at the single tool-execution entry point, and keep the truncated flag in render-only metadata
- Better still, give tools real pagination documented in the description; truncation is the last resort, and overall budget split belongs to compaction
答题要点
- 先算代价:结果回灌后每一轮都整体重发,一次超长结果的成本是剩余轮次乘以一次
- 从中间截、头尾都留,头多分一点;只留头会丢掉报错末行与汇总行这类结论信息
- 必须插一行说明:截掉多少、全文多少、想看中间怎么做,模型看不懂省略号但看得懂指令
- 截断放在工具执行的统一入口,不放在每个工具里;已截断标记只给渲染看不回灌
- 更好的做法是工具自带分页并写进描述,截断是最后一道保险;整体预算分配属于压缩那一层