Hooks and Background Tasks: Lifecycle Hooks, Deterministic Checks, and Notifications That Don't Interrupt the Conversation
Some things shouldn't be left to the model's judgment: implement lifecycle hooks that insert deterministic checks and formatting before and after tool execution, then implement background tasks so a long-running command doesn't block the conversation and notifies the REPL when it finishes.
Today's Goals
- Design a hook's trigger points and input/output protocol, and explain what it can and can't block
- Implement background-task launch, status query, and completion notification without interrupting an ongoing conversation
- Judge which constraints belong in a hook and which belong in the prompt or the permission rules
Seventeen days went into making the model better at its job. Today is the first day we go the other way: we take some of the deciding away from it. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
The shop floor's automatic inspection station
On a production line, each station is followed by an automatic inspection post: the part comes out, a caliper measures it, and if the number is off the line stops. The post is not clever — it measures one dimension against one threshold. That is precisely why it is there: a post that only holds a caliper gives the same answer on the ten-thousandth part; a veteran who looks and thinks gets tired by then.
Everything we added to mca so far was in the "make it smarter" family: more tools, better context, someone else's experience, helpers that split the work. All of it pushes the outcome in a good direction, but what it pushes is probability. A rule in the system prompt — say, "read a file before you edit it" — is followed most of the time, and the whole problem hides in most of the time: the one file it edits from memory will be the one somebody else already changed, the exact replacement lands in the wrong place, and that mistake survives code review because it looks completely normal.
So today we build that inspection post: no judgment, no reasoning, one interception before a tool runs and one after. Yesterday we dispatched a whole model to work for us; today is the mirror image — we take a slice of the deciding back from the model and give it to code. There is a reason this arrives only on day eighteen: you have to see how much a model can do before you understand which parts it should not be doing.
A rule you can settle with one if does not belong to the model
Start with the criterion, because everything below grows out of it: if you can decide a rule with a single if, it should not be something the model has to remember.
A prompt makes the model usually right; a hook makes it always right, and the distance between those two words is the entire reason this machinery exists. The price is just as clear: a hook can only express what can be written as code, and "is this code any good" never will be. So the two are not substitutes but a division of labor — prompts own judgment, hooks own discipline. Day sixteen phrased it as "the craft card owns judgment, code owns discipline"; today is the same criterion landing a second time.
It also needs a clean line drawn against day five's permission rules, because the two look alike. Permission rules answer "who is allowed to do this"; a hook answers "is doing it right, right now." A permission rule is a static table matched on tool name and path: it can express "do not edit files under the test directory," and it cannot express "has this file been read in this session" — that depends on the state of the conversation at this instant. Any check whose criterion depends on state, or on the content being written, can only be a hook.
Four trigger points, and only one of them can say no
A session does not have many natural boundaries. This course takes four:
Mermaid source
flowchart TD
A[session starts: session_start] --> B[the user says something]
B --> C[the model wants a tool]
C --> D[before the tool: pre_tool]
D -->|allow| E[the tool actually runs]
D -->|veto| F[the tool never ran<br/>feed back a failed result]
E --> G[after the tool: post_tool]
G --> H[the result goes back to the model]
F --> H
H --> I[session ends: session_end]Of the four, only pre_tool can veto, and that has to stick. A veto means "this did not happen," and once a tool has finished, the file is already edited and the command has already run. Saying "no" at that point is a lie you tell yourself.
Which means post_tool blocking is a different thing entirely: it undoes nothing, it marks the call failed so day six's self-correction machinery takes over. What the model sees is not "success" but a concrete failure report, and it goes back and cleans up on its own. Today's test-running hook works exactly this way: the model put the guard on the wrong condition, the file really was changed, the call was marked failed, the test output came back verbatim, and its next step got it right. Conflating the two kinds of blocking is the most common mistake in hook protocol design — do it and you end up with a mechanism you believe can undo a write, which in fact undoes nothing.
The protocol: what goes in, and whose failure it is
One detail in a hook's input is worth naming: the arguments handed to it are an already-parsed object, not raw JSON text. Letting every hook parse them again copies day three's "the arguments might not be valid JSON" problem into every hook, each handling it differently.
The output has two bits that are easy to mix up: whether the hook itself succeeded, and whether this call should be stopped, are separate questions. A formatting hook that found nothing to format has failed, and that should stop nothing; stopping is decided by the hook's declared blocking flag. Two more things must be caught by the execution layer, and both surface only when a hook is written badly:
async function runOne(hook: HookDef, input: HookInput) {
const startedAt = Date.now()
// A timeout counts as failure, never as a pass: treating a check that cannot
// finish as a pass quietly disables it exactly when it is needed most —
// on a workspace large enough that the check does not finish
const timeout = new Promise<HookOutcome>((resolve) => {
setTimeout(() => resolve({ ok: false, note: 'timed out' }), hook.timeoutMs).unref()
})
try {
return { outcome: await Promise.race([hook.run(input), timeout]), ms: Date.now() - startedAt }
} catch (error) {
// A hook that throws is still just "this hook failed"; the loop needs no change
const message = error instanceof Error ? error.message : String(error)
return { outcome: { ok: false, note: `hook threw: ${message}` }, ms: Date.now() - startedAt }
}
}async def run_one(hook: HookDef, data: HookInput) -> tuple[HookOutcome, int]:
started = time.monotonic()
try:
# wait_for really cancels the coroutine on timeout, which is cleaner than a race
outcome = await asyncio.wait_for(hook.run(data), hook.timeout_ms / 1000)
except asyncio.TimeoutError:
outcome = HookOutcome(ok=False, note="timed out")
except Exception as error: # catch everything: a bad hook must not topple the loop
outcome = HookOutcome(ok=False, note=f"hook threw: {error}")
return outcome, int((time.monotonic() - started) * 1000)One point deserves emphasis: Promise.race stops nothing. JavaScript cannot cut short a function that is already awaiting, so that timeout only guarantees the caller stops waiting. What actually stops is the timeout the hook passes to its own child process — both of today's slow hooks run child processes, so they really do stop. Python's wait_for does cancel the coroutine, which is cleaner, but a coroutine waiting on a child process still needs a timeout on that child. Catching exceptions comes from the same discipline: when one part breaks, the worst outcome should be one part missing — day fifteen said it about a dropped server, day sixteen about a broken skill package.
Should a hook's output be fed back to the model
This is today's second judgment call. A hook has something to say when it finishes. Does that go to the user's screen, or also into the tool result the model reads?
There is exactly one criterion: would the model's next decision be different if it knew.
A formatting hook stripping trailing spaces changes nothing the model would do — but the moment it modifies file content it has to say so, or the next exact replacement will try to match text that no longer exists. A red test suite it must know about, or it walks on believing the fix is in. A session-start check reporting "the workspace looks fine" is pure noise.
The feedback needs no protocol change: append the hook's words to the body of the tool result. ToolResult.content was always "text fed back to the model," and a hook's output belongs there.
const result = await tool.run(args, ctx)
const post = await runHooks(selectHooks(state.hooks, 'post_tool', tool.name), {
event: 'post_tool',
tool: tool.name,
args,
result,
cwd: ctx.cwd,
})
if (post.feed.length === 0) return result
return {
...result,
// A failed blocking hook marks the call failed. It undoes nothing; it only lets
// the model know that move did not land, so it can go back and clean up
ok: post.denied ? false : result.ok,
content: `${result.content}\n\nHook results:\n${post.feed.join('\n')}`,
}result = await tool.run(args, ctx)
post = await run_hooks(select_hooks(state.hooks, "post_tool", tool.name), HookInput(
event="post_tool", tool=tool.name, args=args, result=result, cwd=ctx.cwd,
))
if not post.feed:
return result
# dataclasses.replace: swap only the two fields that change, carry the rest through
return replace(
result,
ok=False if post.denied else result.ok,
content=result.content + "\n\nHook results:\n" + "\n".join(post.feed),
)The lab shows this feedback genuinely changing behavior: the hook ran the tests on the model's behalf and fed back "all four cases green, you do not need to run them again," and in that turn it never called run_command a second time. Drop that sentence and the tool list grows one fully redundant test run.
The veto branch's failure message has its own requirement: it must say plainly that retrying unchanged produces the same result. Day six taught the model "if it fails, try again," and a deterministic rule answers identically ten thousand times. Leave that sentence out and you watch it walk into the same wall three times before the loop detector picks it up.
Where to hang it: three options, and the dullest one wins
Option one is to change the loop, splicing something in around tool execution. Rejected: the loop already owns backoff, limits, loop detection and approval; a fifth job and it stops being a loop and becomes the file that does everything. Option two is two new event types in the event protocol. Also rejected: the rendering layer has no need to know how hooks run — the same call day five made when approvals stayed out of the protocol.
Option three is to decorate the tool definition itself. What a hook does is "a little more work around a tool running," and the run inside a tool definition is exactly "a tool runs." Pick it and the day's wiring is one line, plus a free bonus: remote tools, scripts shipped with a skill, yesterday's subagent-dispatching tool, and any tool added later pick up hooks with no change at all — they were always the same interface.
Background tasks: the process, the log, the status query
Different subject. Some commands simply take a long time — a full end-to-end suite, an install, a build — and running one in the foreground parks the whole conversation.
Background tasks need three pieces and cannot skip one. The process has to be its own process group, with the parent not waiting on it. The log has to hit disk, because nobody is watching and the output may be tens of thousands of lines or may not arrive for half an hour. The status needs a queryable table — the piece most often forgotten, and without it a background task is something you start and can never find again, which the user experiences as "it apparently did nothing."
The tool exposed to the model is called run_background, and two sentences in its description are mandatory: what comes back is an acknowledgment, not a result; and do not claim success until the system says it finished. Nearly every tool the model has seen hands back an answer on return, so say nothing and it will take a receipt and tell the user the tests have passed.
Progress travels a separate road: the lab starts a tiny receiver bound to localhost only (port 3118), and a task reports to it whenever it wants. Why not parse its standard output? Because standard output is a log for humans, not a protocol for programs — parsing progress from it means agreeing on a prefix and writing a parser, and one unrelated line of output fools the whole thing. One more ruling: progress is pulled, completion is pushed. Push a notification for three progress reports per second and the terminal is unusable. Today covers processes and notifications only; whether two tasks step on each other belongs to yesterday's isolation section.
How a notification gets back to a terminal that is waiting for input
This is today's harder half. A background task speaks at an arbitrary moment, and the terminal is single-threaded — at any instant it is in one of three states, and whether interrupting is allowed differs in each:
| State right now | Can it interrupt | Why |
|---|---|---|
| Streaming output | Absolutely not | The typewriter writes character by character into one line; an inserted line tears the passage in half |
| Waiting on an approval or a question | No | The user is staring at a question, about to answer; a stray "task finished" reads as part of the question |
| Waiting for user input | Conditionally | Only while the input line is still empty, or you wipe out what they half typed |
So the queue's default action is to hold, releasing at two safe moments only: after a turn ends, and at the instant of "waiting for input with an empty line."
push(notice: Notice): void {
this.forModel.push(notice)
// If we may interrupt, print now and repaint the prompt; otherwise hold it
if (this.sink?.canInterrupt()) {
this.sink.out(`\n${notice.text}`)
this.sink.redraw()
return
}
this.pending.push(notice)
}def push(self, notice: Notice) -> None:
self.for_model.append(notice)
# Same decision, written with the walrus operator: grab the sink and ask it in one step
if (sink := self.sink) is not None and sink.can_interrupt():
sink.out("\n" + notice.text)
sink.redraw()
return
self.pending.append(notice)That forModel is the second road and cannot be dropped: notices for the human and notices for the model are queued separately. Characters on a screen are not in the message array, so the model cannot see them. Without it the user hits a genuinely strange moment — they read "task finished," ask the model, and are told it is still running. The implementation is three lines: attach the held status as one message at the start of the next turn.
What this costs: it slows down every single edit
A mechanism is only fully explained once its cost is on the table. A hook's cost is unambiguous: it slows down every tool call, and that bill is multiplied by the number of calls.
The lab's test-running hook takes a bit over a hundred milliseconds on a sandbox repo with four cases (that number is not reproducible — it differs on every machine and every run; read the order of magnitude only). One edit paying that is nothing; twenty edits is two or three seconds. Move to a real repo whose full suite takes three minutes and the same hook makes this Agent unusable.
So in a real project it changes shape: either it runs only the files that were touched, or it moves to the pre-commit trigger point — twenty edits, one check. That arithmetic belongs in the choice of trigger point: the closer to every tool call, the more timely the check and the higher the cost. Which is why each hook's duration must be printed on screen — otherwise the user just feels that "this thing is slow today." A cost nobody can see is a cost nobody manages.
Source Reading
Hands-On Lab
The lab hangs six hooks across the four trigger points: a read-before-edit recorder paired with its guard, formatting, test running, a session-start environment check, and a shutdown reminder. The background side has a task table, logs, a progress receiver and two slash commands. All five exercises sit where things "run without it, are trustworthy only with it": timeouts and caught exceptions, veto semantics, feedback, logs on disk, notification queuing. The starter passes five of fourteen unmodified.
- Add timeouts and exception catching to hooks, and watch checks two and three go green — remember a timeout counts as failure, never as a pass.
- Wire up the veto that is computed and then ignored, and watch the model's edit-from-memory get stopped with the file unchanged, byte for byte.
- Feed hook output into the tool result and mark the call failed when blocking, then watch the model go back and get it right on its own.
- Route a background task's output to a log file, and watch /tasks with a task number read that output back.
- Change notifications from "print on arrival" to queued, run MOCK=1 SELFTEST=1 pnpm start for all fourteen checks, then use the README's pipe commands to see the three behaviors.
Acceptance is five ticks: fourteen of fourteen self-test checks pass; an edit without a prior read is stopped with the file unchanged; after a wrong edit that call is marked failed with the test output fed back; the background task reports three of three progress steps through 3118 and its log reads back; and the end-to-end turn's three tool calls contain no run_command, because the hook ran the tests instead.
Interview Questions
Three questions today, on the division between deterministic and probabilistic constraints and on how an asynchronous mechanism finishes — not on "what is a hook":
- Which constraints belong in a hook and which in the prompt? What is the criterion?
- How is a hook designed so it can veto without deadlocking the Agent?
- With a long-running command in the background, how do its status and result return to the conversation?
Full prompts, analyses and key points are in this course's day-eighteen question bank. Question two discriminates most: most people get as far as "add a timeout," and few lay out all three layers — a timeout counts as failure, blocking needs a declared default posture, and the failure message must say that retrying is pointless.
Checklist and Tomorrow
- I can state the criterion "a rule you can settle with one if should not be the model's to remember," and the line between hooks and day five's permission rules
- I can say why, of the four trigger points, only the one before tool execution may veto
- I can explain what blocking after a tool actually means, and what it cannot undo
- I can say why a veto returns a failed result instead of throwing, and what the failure message must contain
- I can state the criterion for feeding hook output back to the model, with one example on each side
- I can say why a hook timeout counts as failure, and what a race actually fails to stop
- I can explain why hooks decorate the tool definition rather than changing the loop or extending the event protocol
- I can name the three pieces of a background task, and what breaks when the status query is the missing one
- I can state the terminal's three states, and why a notification may only interrupt in one of them
- I can explain why notices for the human and notices for the model are queued separately
- I can do the arithmetic on a hook's cost, and say why an earlier trigger point costs more
Tomorrow is D19, "Multimodal Input: Pasting Screenshots, Image Validation, and Fixing Code From a Screenshot." Today gave mca a layer of discipline that does not argue; tomorrow gives it another sense — and the first trap on that road is this: a model that cannot see images does not raise an error, it invents an answer.
Interview questions
Which constraints belong in hooks and which belong in the prompt? What is the criterion?哪些约束该用钩子实现,哪些该写进提示词?判据是什么?
Common in ChinaCommon overseasBasic#hooks#prompt-engineering#agent-designHow to reason about it · think before answering
- This tests whether you know the reliability ceiling of a prompt. People who have not built one answer "important things go in the prompt, very important things go in code", which is circular; people who have built one carry a criterion they can apply on the spot.
- How to break it down - state the criterion, then the cost on each side, then separate out a third mechanism people tend to conflate with hooks.
- The criterion - if a rule can be decided by a single if statement, it should not be left to the model to remember. A prompt makes the model usually right; a hook makes it always right, and the gap between those two words is the entire reason hooks exist.
- Name the cost on both sides. A hook can only express what can be written as code, and "is this code any good" never can, so prompts cannot be replaced. Meanwhile every hook slows down every tool call, and that bill is multiplied by the number of calls.
- Separate hooks from permission rules, which are the easiest thing to confuse them with. Permission rules answer who is allowed to do this and are a static table matched on tool name and path; hooks answer whether this is the right thing to do right now, and may key off session state or the content being written. Whether this file has been read in this session is something a permission table cannot express.
- A concrete contrast carries the point - read the file before editing it is usually obeyed when it lives in the prompt, and always obeyed when it is a hook, at a cost of a dozen lines.
- Likely follow-ups - how to choose when a rule could be either; what happens when a hook itself is wrong (there must be a master switch); whether to delete a rule from the prompt once it becomes a hook.
分析过程 · 先想清楚再作答
- 这题在考「你知不知道提示词的可靠性上限」。没实现过的人会答「重要的写提示词,非常重要的写代码」,这是同义反复;实现过的人手里有一条能当场判的判据。
- 怎么拆:先给判据,再说两边各自的代价,最后补一条容易被混进来的第三方(权限规则)。
- 判据:一条规则如果你能用一个 if 判断出来,它就不该交给模型去记。提示词让模型「通常」做对,钩子让它「总是」做对,这两个词之间的差距就是钩子存在的全部理由。
- 两边的代价要都说:钩子只能表达能被写成代码的东西,「这段代码写得好不好」永远写不成代码,所以提示词不可能被替代;而钩子多了会拖慢每一次工具调用,这笔账要乘以调用次数。
- 还要跟权限规则划界,它们最容易混:权限规则回答「谁有权做这件事」,是一张按工具名与路径匹配的静态表;钩子回答「这件事此刻做得对不对」,判据可以依赖会话状态与写入内容。「这个文件本轮读过没有」就是权限规则表达不了的。
- 举一个具体的对照最能说明问题:「改文件之前先读一遍原文」写在提示词里是大部分时候遵守,做成钩子是百分之百,而实现代价只有十几行。
- 可预期的追问:一条规则同时能写成两者时怎么选;钩子写错了怎么办(要有总开关);提示词里已经写过的规矩做成钩子之后要不要从提示词里删掉。
Key points
- The criterion - a rule decidable by one if statement should not be left to the model; prompts give you usually, hooks give you always
- Hooks have limited expressive power; anything requiring a judgment of quality still needs the prompt - they divide work rather than replace each other
- Hooks versus permission rules - permissions answer who may act and are a static table; hooks answer whether the act is right now and may key off state and content
- The cost of a hook is that it slows every tool call, multiplied by the number of calls
- Usually keep the sentence in the prompt too, so the model knows the rule exists instead of discovering it by being blocked
答题要点
- 判据:能用一个 if 判断出来的规则就不该交给模型去记;提示词给「通常」,钩子给「总是」
- 钩子的表达力有限,凡是需要判断好坏的仍然只能靠提示词,两者是分工不是替代
- 钩子与权限规则的分界:权限管「谁有权做」,是静态表;钩子管「此刻做得对不对」,判据可依赖状态与内容
- 钩子的代价是拖慢每一次工具调用,而且乘以调用次数
- 做成钩子之后提示词里那句通常仍要留着,让模型知道有这条规矩,免得它撞上去才知道
How do you design hooks so they can veto a call without deadlocking the agent?钩子怎么设计才能既能否决又不至于把 Agent 卡死?
Common in ChinaCommon overseasDeep dive#hooks#reliability#failure-handlingHow to reason about it · think before answering
- This tests whether your own hooks have ever bitten you. People who have not built them stop at "add a timeout"; people who have answer in three layers - veto semantics, failure posture, and what the execution layer must catch.
- How to break it down - say where a veto is even possible, then how a veto should be expressed, then the two things the execution layer must guarantee.
- Layer one - a veto is only possible before the tool runs. Afterwards the file is already changed and the command already executed, so saying no is self-deception. A post-tool hook's block can only mean marking the call as failed so the model cleans up after itself; it undoes nothing. Conflating the two is the most common mistake in this protocol.
- Layer two - express a veto as a failed tool result, not a thrown exception. A throw gets flattened into "execution failed" by the registry, the model cannot see what happened, and it retries verbatim. Returning a result that states the reason and the next step lets the self-correction loop take over. That message must say that retrying identically changes nothing, because retry is exactly the default behavior the model has learned.
- Layer three - two guarantees against deadlock. Every hook needs a timeout, and a timeout must count as failure rather than success; counting it as success silently disables the check precisely when it matters most. And exceptions must be caught inside the hook layer, so a broken hook costs you one check rather than the whole agent. Also know that a race-style timeout does not stop the work in flight - only the timeout handed to the child process does.
- The last layer is posture - blocking should not be the default. A hook that blocks readily turns the agent into something that refuses to move, and that failure is the hardest to diagnose because all the user sees is that it will not do anything today. So there must also be a master switch to turn hooks off and keep working.
- Likely follow-ups - serial or parallel execution; whether later hooks still run after a blocking one fails; what to do when a hook itself needs to call a model.
分析过程 · 先想清楚再作答
- 这题在考「你有没有被自己写的钩子坑过」。没实现过的人答「加个超时」就没了;实现过的人会分三层答:否决语义、失败姿态、执行层的兜底。
- 怎么拆:先说清否决只在哪个触发点成立,再说否决怎么表达,最后说执行层必须兜住的两件事。
- 第一层,否决只在工具执行之前成立。执行之后文件已经改了、命令已经跑了,那时候说「不行」是自欺——执行后钩子的「阻断」只能是「把这次调用判成失败」,让模型自己回头收拾,它撤销不了任何东西。把这两种阻断混为一谈是设计这套协议最常见的错误。
- 第二层,否决要表达成一条失败的工具结果而不是抛异常。抛异常会被注册表兜成一句「执行失败」,模型看不出发生了什么就会原样重试;返回一条写清原因与下一步的结果,自纠机制就自动接手了。失败信息里必须写「原样重试不会有不同结果」——模型学到的默认动作就是重试一次。
- 第三层是防卡死的两件事:每个钩子必须有超时,而且超时要算失败不算通过(算通过等于在最需要检查的时候悄悄关掉检查);钩子抛的异常必须兜在钩子这一层,一个写坏的钩子最坏的后果应该是少一个检查。还要知道 race 之类的写法停不住正在跑的东西,真正能停的是传给子进程的那个超时。
- 最后一层是姿态问题:默认不该是阻断。一个动不动就阻断的钩子会让 Agent 变成走不动路的东西,而那种故障最难查——用户看到的只是「它今天什么都不肯做」。所以还要有一个总开关,让人当场关掉再继续干活。
- 可预期的追问:多个钩子是串行还是并行;阻断型钩子失败之后后面的钩子还跑不跑;钩子自己需要调模型时怎么办。
Key points
- A veto is only possible before execution; blocking afterwards only marks the call failed and undoes nothing
- Express a veto as a tool result with ok false rather than a throw, so the self-correction loop takes over
- The failure message must say that an identical retry changes nothing, or the model will retry by default
- Every hook needs a timeout that counts as failure, and exceptions must be caught at the hook layer so one broken hook costs one check
- Default to warning rather than blocking, and keep a master switch so a bad hook can be turned off on the spot
答题要点
- 否决只在工具执行前成立;执行后的「阻断」只是把这次调用判成失败,撤销不了任何东西
- 否决表达成一条 ok 为 false 的工具结果,不要抛异常,让自纠机制接手
- 失败信息里必须写清「原样重试不会有不同结果」,否则模型会照默认动作重试
- 每个钩子必须有超时,超时算失败不算通过;异常兜在钩子这一层,坏一个钩子只损失一个检查
- 默认姿态是只警告不阻断,并且要留一个总开关,钩子写错时能当场关掉
When a long-running command goes to the background, how do its status and result get back into the session?长时间运行的命令放到后台,状态与结果怎么回到会话里?
Common in ChinaCommon overseasIntermediate#background-tasks#async#terminal-uxHow to reason about it · think before answering
- This tests whether you have actually handled async completion. People who have not answer "print a line when it finishes"; people who have know the hard parts are when to print and who to print it for.
- How to break it down - the three pieces a background task needs, then the timing of the notification, then the fact that humans and the model are two separate channels.
- The three pieces are process, log, and status. The process gets its own process group and the parent does not wait on it. The log must go to disk, because nobody is watching - output may run to tens of thousands of lines or arrive half an hour later. And there must be a queryable status table; this is the piece people forget, and without it a background task is something you can start and never find again.
- Be explicit about the return value - the model gets an acknowledgment, not a result, and that sentence must be in the tool description. Almost every tool the model has seen returns an answer on call, so without saying so it will wave the receipt around and tell the user the tests are done.
- Notification timing is the real difficulty. A terminal is only ever in one of three states - streaming output, where interrupting tears the typewriter in half; waiting on an approval or a question, where a stray line reads as part of the question; and waiting for input, where interrupting is fine only if the input line is still empty, or you wipe out what the user half typed. So the default is to queue and drain at a safe moment.
- The most commonly missed piece - queue notifications for the human and for the model separately. Text on screen is not in the message array, so the model cannot see it. Without that, the user sees the task complete and then hears the model say it is still running.
- Progress and completion travel differently - progress is pulled, because the user asks for it, while completion is pushed exactly once and must reach the user. Pushing progress three times a second floods the terminal.
- Likely follow-ups - whether a failed task should interrupt the current turn; what to do with tasks still running at exit; how to isolate parallel tasks (a different day's topic).
分析过程 · 先想清楚再作答
- 这题在考「你有没有真的做过异步收尾」。没做过的人答「跑完打印一行」,做过的人知道难点全在「什么时候打印」和「打给谁看」。
- 怎么拆:先说后台任务三件套,再说通知的时机,最后说给人看与给模型看是两条路。
- 三件套是进程、日志、状态查询。进程要自成进程组,父进程不等它;日志必须落盘,因为没人看着它,输出可能有几万行也可能半小时后才产生;状态要有一张能查的表——这件最容易漏,没有它,一个后台任务就是启动完就再也找不到的东西。
- 工具返回值要说清:模型拿到的是受理回执不是执行结果,而且这句话必须写进工具描述里。模型见过的工具几乎都是调用完就拿到答案,不明说它会拿着回执告诉用户「已经跑完了」。
- 通知时机是真正的难点。终端在任何时刻只有三种状态:正在流式输出(绝对不能插,会把打字机撕成两半)、正在等审批或等回答(不能插,用户会当成问题的一部分)、正在等用户输入(能插,但只有输入行为空时才行,否则会冲掉他敲了一半的字)。所以默认动作是攒着,只在安全时刻倒出来。
- 还有一条最容易漏的:给人看的通知和给模型看的通知要分开攒。屏幕上的字不在消息数组里,模型看不见——少了这一条,用户明明看到「任务完成」,一问模型却说「还在跑」。
- 进度与完成的路子也不一样:进度是拉的(用户主动查),完成是推的(只发生一次,必须送到眼前)。每秒推三次进度会把终端刷爆。
- 可预期的追问:任务失败了要不要打断当前对话;退出时还在跑的任务怎么办;多个任务并行时怎么隔离(那是另一天的题目)。
Key points
- Three pieces - a process in its own group, a log on disk, and a queryable status table; without status the task cannot be found again
- The tool returns an acknowledgment, not a result, and the description must say so or the model treats the receipt as the outcome
- Queue notifications by default; the only terminal state that permits interrupting is waiting for input with an empty input line
- Queue separately for the human and for the model, or the model will insist the task is still running
- Progress is pulled and completion is pushed; pushing every progress update floods the terminal
答题要点
- 三件套:自成进程组的进程、落盘的日志、可查询的状态表,缺状态那件任务就找不回来
- 工具返回的是受理回执不是结果,这句话必须写进工具描述,否则模型会拿回执当结论
- 通知的默认动作是攒着;终端只有「正在等输入且输入行为空」这一种状态允许插话
- 给人看的通知与给模型看的通知分两条路攒,否则模型会说「还在跑」
- 进度是拉的、完成是推的;每次进度都推通知会把终端刷爆