Task Lists and Self-Planning: the Todo Tool, Progress Rendering, and Early Detection of Spinning
Give long tasks a dashboard: implement a tool that lets the model maintain its own todo list, render that list live in the terminal, use it to keep the model to one thing at a time, and use changes in the list to catch the model spinning in circles at the earliest moment.
Today's Goals
- Design a tool that lets the model maintain its own task state, and explain why it improves long-task performance
- Implement incremental rendering of the list in the terminal without stepping on streaming text
- Use changes in the list to recognize stalling and spinning, and give an intervention strategy
Yesterday gave it rules; today gives it a checklist. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
The checklist pinned to the desk: how far along, at a glance
The new hire can work, knows which file to open, and knows the team rules. So you confidently hand them something larger: fix this failing test.
Forty minutes later you pass their desk and ask "how far along?" They look up and start recalling: I ran the tests, then looked at calc.js, then wanted to change it, but there seemed to be another problem in the tests, so I went to look at the test directory, and then...
The problem is not that they did no work; it is that they cannot answer "how far along" either. They have a plan in their head, that plan exists only in their head, and it shifts slightly with every file opened.
You have seen the fix in every workshop: pin the checklist to the desk. Three lines, a box in front of each, ticked as they are done. You know at a glance in passing; they look up and know what is next.
Today gives mca that board, with one counterintuitive property: we do not maintain it for the model, the model maintains it itself. We provide one tool (update_todos) and one hard rule; it writes and it ticks.
Why does it help? Because the model has no memory — day one said so: all it sees each turn is the text in the context. A "plan" in its head that never lands as text in the context does not exist next turn; and when it reads next turn the checklist it wrote last turn, "how far along am I" turns from a question requiring recall into a fact it can read.
That is today's core proposition: externalized state is steadier than an implicit plan.
Why the model maintains it: turning planning into an observable write
A natural objection: if we want a checklist, why not decompose the task and maintain the list ourselves?
Because — and the reason is not that we cannot — our checklist and its actual steps will not line up. You break it into three steps up front, it reaches step two and finds reality is otherwise: the checklist becomes a wrong map it has no permission to fix. Its only options are pretending to follow it (the checklist is fake) or ignoring it (the checklist is useless).
Letting it write brings three benefits:
- Planning becomes an observable write. You see how it intends to proceed rather than learning after the fact — especially valuable when it intends to do the wrong thing.
- It re-reads its own plan every turn. Those three lines in the context are its anchor, far harder than a prompt saying "please follow the plan."
- Progress gains a measure independent of any specific tool. That pays a large dividend in the last section: judging whether an Agent is moving forward is not about how many tools it called but whether the checklist moved.
The cost is real too: each update is a tool round trip. A three-item list may cost three or four extra turns just to maintain. So it has a clear boundary — the criterion is in the last section.
Only three states: a fourth gives the model an escape hatch
The checklist's data model looks least technical, and every field is a trade-off.
Only three states: pending, in progress, done. You will likely want a fourth — "blocked," or "in progress but stuck." Do not.
Two reasons. The cheap one: another state means another judgment on every update, costing tokens and possibly wrong. The important one: "blocked" gives it a dignified escape hatch. A task it cannot finish gets marked blocked and is legitimately bypassed while the checklist looks fine; with only three states, being unable to finish means saying so — and only then can you help. For the same reason there is no "abandoned" state.
The second decision is both write forms are needed:
- Whole-table replacement (a complete
items): for re-planning. "I thought it over, it is actually four steps." - Single-item update (an
idplus astatus): for finishing a step, and the most common action.
Why not only whole-table replacement? The model would resend the entire table for every status change — hundreds of tokens each time for a ten-item list, with a fair chance of corrupting another item's text while resending. Why not only single-item updates? Then it could never re-plan mid-task. The two forms cover two different actions.
The third decision: the checklist is ephemeral and is not persisted. It is this task's working surface, not a fact (that is tomorrow's memory) and not a process record (that is day seven's session log). Once the task ends the checklist is meaningless, and saving it only shows the next conversation a stale to-do list.
Only one in progress at a time: one hard invariant beats "please focus"
Today's most important rule, important because of an observation: an Agent with three tasks "in progress" does a little of each, finishes none, and looks extremely busy on the checklist.
You can of course write "please do one thing at a time" in the system prompt. It sometimes complies and sometimes not, and you cannot tell which. Turn it into a rule the program rejects and the effect is different:
replace(next: Array<{ id?: string; text: string; status: TodoStatus }>): TodoWrite {
const running = next.filter((item) => item.status === 'in_progress')
if (running.length > 1) {
return {
ok: false,
changed: 0,
// Name which ones, so it knows next turn which to change.
// Saying only "there cannot be several" leaves it guessing - and a wrong guess
// is another round trip
note:
`only one task may be in_progress; you gave ${running.length}: ` +
`${running.map((item) => item.text).join(', ')}. ` +
'The list was not changed. Set only the one you are actually doing to in_progress, leave the rest pending.',
}
}
// Touch the data only after validation: the whole table changes or none of it does
this.items = next.map(normalize)
this.version += 1
return { ok: true, changed: countChanges(before, this.items), note: describe(this.items) }
}def replace(self, next_items: list[TodoItem]) -> TodoWrite:
running = [item for item in next_items if item.status == "in_progress"]
if len(running) > 1:
names = ", ".join(item.text for item in running)
return TodoWrite(
ok=False,
changed=0,
# Name which ones: saying only "there cannot be several" leaves it guessing,
# and a wrong guess is another round trip
note=(
f"only one task may be in_progress; you gave {len(running)}: {names}. "
"The list was not changed. Set only the one you are actually doing to "
"in_progress, leave the rest pending."
),
)
# Touch the data only after validation: the whole table changes or none of it does
changed = sum(1 for item in next_items if self._status_of(item.id) != item.status)
self._items = [replace_id(item, i) for i, item in enumerate(next_items)]
self._version += 1
return TodoWrite(ok=True, changed=changed, note=describe(self._items))Three implementation details, each worth an interview question of its own:
- On violation, nothing in the table changes. A half-applied change is the worst outcome: the model believes it was written while the state is something else.
- The correction must name which ones. Saying only "there cannot be several" leaves it guessing, and a wrong guess is another round trip. This shares a channel with day three's bad-argument feedback and day six's spin interruption — a failed tool result is its instruction sheet for the next step.
- Validation lives in the data model, not in the tool. Day thirteen's plan mode writes this list too and should not duplicate the validation.
One more easily missed rule: writing an item to the state it already has is allowed but counts as no change, and the version does not move. Because the version number is the only progress measure for the stall detector at the end of this chapter — repeatedly writing the same status would raise it, giving the model a cheat it will use without any malice.
There is only one cursor: the checklist stays in a fixed area while text keeps growing
Now today's second technical difficulty, the kind you do not discover without building it.
The shape is simple: a terminal has one cursor and two things want it. Streaming text keeps growing downward, and the checklist must stay put. Write a checklist line directly and the next delta pushes it up, and half a second later the screen holds a dozen versions of it.
The first decision is a bottom panel, not a top one. Counterintuitive — should a dashboard not be at the top? No: the top requires counting how many lines the text has scrolled, and text wraps automatically with line counts determined by terminal width, so you need to know the user's window width and handle them resizing mid-run. The bottom only needs to know how many lines the checklist has, the one number you actually know.
Then four actions:
const SAVE = '\u001b[s' // save the cursor position
const RESTORE = '\u001b[u' // return to the saved position
const CLEAR_BELOW = '\u001b[0J' // clear from the cursor to the end of the screen
export class TodoPanel {
/** Draw: remember where the text stopped, then newline and draw the checklist */
show(): void {
const rows = renderRows(this.list)
if (rows.length === 0) return
if (!this.tty) return void this.write(`${rows.join('\n')}\n`)
this.write(`${SAVE}\n${rows.join('\n')}\n`)
this.visible = true
}
/** Erase: return to that half line of text and clear everything below. The text is untouched */
hide(): void {
if (!this.tty || !this.visible) return
this.write(`${RESTORE}${CLEAR_BELOW}`)
this.visible = false
}
/** The key layer: every text output is sandwiched between hide and show */
wrap(out: (chunk: string) => void): (chunk: string) => void {
if (!this.tty) return out // no cursor means no cursor control codes
return (chunk) => {
this.hide()
out(chunk)
this.show()
}
}
}SAVE, RESTORE, CLEAR_BELOW = "\x1b[s", "\x1b[u", "\x1b[0J"
class TodoPanel:
"""A bottom panel. A contextmanager expresses "write in between" exactly."""
def show(self) -> None:
rows = render_rows(self.list)
if not rows:
return
if not self.tty:
self.write("\n".join(rows) + "\n")
return
self.write(SAVE + "\n" + "\n".join(rows) + "\n")
self.visible = True
def hide(self) -> None:
if not self.tty or not self.visible:
return
self.write(RESTORE + CLEAR_BELOW)
self.visible = False
@contextmanager
def parked(self) -> Iterator[None]:
"""with panel.parked(): print(...) - the panel steps aside and comes back"""
self.hide()
try:
yield
finally:
self.show()That wrap layer is the whole secret of the panel, and its finest property is that the render layer changed by not one line. renderTurn still receives only a "write a string" function and knows nothing about a panel below. That is day two's boundary paying a dividend for the second time.
Finally the degradation path: outside a TTY there is no cursor, so send no cursor control codes and degrade to reprinting the whole block on each change. That matches day two's typewriter rule — emitting them and producing a stream of garbage is worse than not having the feature.
The stall signal: if the checklist does not move, there is no progress
Today's most valuable section, valuable because of a division of labor:
| Spin detection (day six) | Stall detection (today) | |
|---|---|---|
| What it watches | character-identical repeated calls | whether there is progress |
| Measure | consecutive identical "tool name + argument text" | how many turns the checklist version has not moved |
| What it misses | slightly different each time while going nowhere | mechanical single repetition (already covered) |
| Intervention | do not execute this call, feed back a note | warn once; on a second hit, stop this turn |
What day six's detector misses is exactly what today catches: doing something different every turn while finishing nothing. Read A, read B, read C — arguments differ every time, spin detection never fires, and the checklist has not changed a word from start to finish. That is the most common runaway shape in long tasks and the hardest to spot, because it looks busy throughout.
So the stall measure must be tool-independent. The lab uses two signals, both drawn from what already exists:
check(rounds: number, revision: number, maxReopen: number): StallSignal | null {
// A changed version means progress; record which round it changed in
if (revision !== this.lastRevision) {
this.lastRevision = revision
this.lastChangeRound = rounds
}
// Reopening is worse than standing still: it thought it was done and found it was not
if (maxReopen >= this.limits.maxReopen) {
return this.signal('reopen', `${NUDGE_PREFIX}one task has been reopened ${maxReopen} times...`)
}
// revision 0 means the checklist was never written: a job of three steps should not be nagged.
// Without this exemption, any short conversation that skips the checklist gets nudged on turn three
const quiet = rounds - this.lastChangeRound
if (quiet >= this.limits.quietRounds && revision > 0) {
return this.signal('quiet', `${NUDGE_PREFIX}the checklist has not changed for ${quiet} turns...`)
}
return null
}def check(self, rounds: int, revision: int, max_reopen: int) -> StallSignal | None:
if revision != self.last_revision:
self.last_revision, self.last_change_round = revision, rounds
# Reopening is worse than standing still: it thought it was done and found it was not
if max_reopen >= self.limits.max_reopen:
return self._signal("reopen", f"{NUDGE_PREFIX}one task has been reopened {max_reopen} times...")
# revision == 0 means the checklist was never written: a three-step job should not be nagged
quiet = rounds - self.last_change_round
if quiet >= self.limits.quiet_rounds and revision > 0:
return self._signal("quiet", f"{NUDGE_PREFIX}the checklist has not changed for {quiet} turns...")
return NoneThat exemption (revision > 0) looks like a small branch and is actually the line between shippable and not: without it, any short conversation that skips the checklist gets nudged on turn three, and that false alarm is worse than no detection — users learn to ignore it fast.
Intervention has two levels, a chance before surfacing, the same idea as day six's spin interruption: the first hit feeds back a reminder to update the checklist or say where it is stuck; the second stops the turn through the same exit as a hard limit.
Two implementation rules worth remembering:
- Feed the reminder back with the user role, not assistant. An assistant message reads to the model as something it said, so it likely continues down the same line; a user message reads as someone prompting it. A correction sent under the wrong role has the opposite effect.
- Reminder events reuse
errorwithretryable: true; no new protocol type was added. All the render layer needs to know is "the loop will go round again," which is exactly the semantics fixed on day six — a continuation of day five's "approval does not enter the protocol" ruling.
In the lab under INJECT=loop both mechanisms take their places: the third identical call is blocked by spin detection, the checklist version stays at 1 at 0/3 done, then comes a line "(system reminder) the checklist has not changed for 3 turns...", and one turn later it stops. These numbers are reproducible under MOCK=1 (depending only on the script and the thresholds); elapsed time and real usage are not.
Which tasks should not use a checklist
The last section is short and decides whether this feature is an asset or a liability.
Do not use a checklist for jobs of three steps or fewer. Use one for "rename this function" and you watch it spend a turn writing "1. find all references 2. rename," then two more turns updating statuses — a thirty-second job becomes five round trips. That is not the checklist's fault, it is a missing criterion. So the base prompt's sentence is conditional: use it above three steps. The judgment belongs to the model, because only it knows how many steps it plans; what we can do is state the condition clearly rather than writing "use a checklist when appropriate."
Conversely, two situations where a checklist is worth most: steps with dependencies where the middle can fail (fixing a test is the archetype: locate, change, verify, and a failed third step returns to the second — with a checklist it knows where to return, without one it starts over); and a task long enough to cross a context compaction (day twelve's subject, but the conclusion can be stated now: the checklist is the class of message most worth preserving during compaction).
One boundary to close on, split between tomorrow and day thirteen: the checklist governs how far this job has got, not whether it should be done. Whether a plan needs the user's approval is day thirteen; which traps in this repository are worth remembering is tomorrow. Three things govern three things: rules come from others, the checklist is about this once, memory is what was learned.
Source Reading
Hands-On Lab
Today leaves five exercises, three of which are "looks harmless, is fatal" traps: whole-table replacement without validating the in-progress count, bumping the version when writing an item to the state it already has (a cheat left for the model), and printing the panel directly with no cursor handling. The starter passes five of fifteen unmodified.
The INJECT=loop item must be verified across processes — it is read into a constant at module load and cannot be changed within one process. The self-test spawns a child, this course's standard technique since day six.
- Complete the checklist's two invariants: neither whole-table replacement nor single-item update may produce a second in-progress item, and on violation nothing changes while a naming correction is fed back.
- Wire single-item update to the tool (
idplusstatus), and make "writing the state it already has" count as no change — the version does not move. - Implement the bottom panel's
hide,showandwrap: text output written in between, degrading to reprinting the block outside a TTY. - Implement stall detection: how many turns the checklist has been still, how many times one item has been reopened, two levels of intervention, and do not forget the "never used the checklist means no nagging" exemption.
- Run the self-test:
MOCK=1 SELFTEST=1 pnpm startshould print 15/15 passed. Checklist state transitions, panel line counts, threshold firing and the version number during a spin are reproducible; elapsed time and the session id are not.
Acceptance is five ticks: the self-test prints 15/15 passed; one conversation takes the checklist from 0/3 done to 3/3; giving two in-progress items is refused with the list unchanged and the model fixes it next turn; update_todos is a read-only tool and does not trigger the approval gate; and under INJECT=loop spin detection and stall detection fire in sequence with the checklist version stuck at 1.
Interview Questions
Today's three questions test the payoff and cost of externalizing planning, not "how to store an array":
- What makes the model maintaining its own checklist better than writing a plan in the prompt? What does it cost?
- With streaming text and a fixed progress area in one terminal, how do you manage the cursor and refreshes?
- How do you detect from runtime data that an Agent is spinning? How do you intervene once you know?
Full bilingual prompts, analyses and key points are in this course's day-ten question bank. Question three is easiest to answer as "detect repeated calls" — that is half of it, and few can say how to catch the "slightly different every time while going nowhere" shape.
Checklist and Tomorrow
- I can explain "externalized state is steadier than an implicit plan" and why the model should maintain the checklist
- I know why there are only three states, and what escape hatch a fourth would open
- I can say which action each write form corresponds to, and why both are needed
- I can explain "one hard invariant beats one prompt sentence," and why nothing changes on violation
- I know why the panel goes at the bottom, and why the
wraplayer leaves the render layer untouched - I can state the division between spin and stall detection, and why the "never used the checklist" exemption is crucial
- I can name two situations where a checklist should not be used, and the two where it is worth most
Tomorrow is D11, "Cross-Session Memory: Explicit Memory, Automatic Memory, and Three Criteria for Retrieval Injection." Today's checklist is ephemeral — it should vanish when the task ends. Tomorrow is the opposite: how a fact worth remembering across sessions gets written down, how it is retrieved, and what must never be remembered. Checklists come before memory because the checklist already posed the question clearly: the checklist is this task's working surface, memory is what is still useful next time — confuse the two and the memory directory becomes a landfill within two weeks.
Interview questions
What does letting the model maintain its own task list buy you over writing the plan in the prompt, and what does it cost?让模型自己维护任务清单,比在提示词里写计划好在哪?代价是什么?
Common in ChinaCommon overseasBasic#self-planning#tool-designHow to reason about it · think before answering
- This tests whether you have thought about why the feature works. Answering it makes the model more organized is empty and invites follow-ups until you break. The signal is a mechanism-level reason plus volunteering the cost.
- How to break it down: start from the basic fact that the model has no memory — every round it sees only the text in the context. So a plan that exists only inside one round's reasoning does not exist in the next. The list externalizes the plan into context text, turning where am I from something to recall into something to read. In one line: externalized state is more stable than an implied plan.
- Why not decompose it ourselves? Because our list and its actual steps diverge. You break the work into three steps up front, it reaches step two and finds reality differs — the list is now a wrong map it has no permission to fix, so it either pretends to follow it (the list is fake) or ignores it (the list is useless). Letting the model write it adds two more benefits: the plan becomes an observable write, so you see the intent before the mistake; and progress gains a tool-agnostic metric, which the third question builds on.
- You must volunteer the cost, and that is the dividing line: every list update is a tool round trip. Maintaining a three-item list can cost three or four extra rounds, and each round resends the whole message array. So the feature has a clear boundary — do not use a list for work under three steps, or a thirty-second task becomes five round trips. Conversely, it pays off most when steps have dependencies and can fail mid-way (you need to know which step to fall back to) and when the task is long enough to survive a context compaction, where the list preserves where am I for a few dozen tokens.
- Cite a concrete design tradeoff to show you built it: keep only three states — pending, in progress, done. Adding blocked looks more complete but hands the model a respectable escape hatch: a task it cannot finish gets marked blocked and legitimately skipped while the list looks fine. With only three states it has to say it is stuck, and only then can you help.
- Likely follow-up: should the list be persisted? No. It is this task's working surface, neither a fact nor a record of process; once the task ends it is meaningless, and persisting it just shows the next conversation a stale to-do list it must first spend a round evaluating.
分析过程 · 先想清楚再作答
- 这题在考「你有没有想过这个功能为什么有效」。答「让它更有条理」是空话,会被追问到底。区分度在于你能不能说出一个机制层面的理由,再主动交出代价。
- 怎么拆:先回到一条最基本的事实——**模型没有记忆,它每一轮看到的只有上下文里的那些字。** 所以「计划」如果只存在于它这一轮的推理里,下一轮就等于不存在。清单的作用是把计划**外化成上下文里的一段文字**,于是「我做到哪了」从一个需要回忆的问题变成了一个可以直接读的事实。一句话概括:**外化的状态比隐含的计划稳。**
- 为什么不是我们替它拆?因为我们拆的清单和它执行的步骤会对不上。你在开始之前拆三步,它执行到第二步发现根本不是那样——清单成了一份错的地图,而它没有权限改;它只能假装照着走(清单是假的)或者不管清单自己干(清单没用)。让它自己写还多两个好处:**计划变成一次可观测的写操作**(你能在它做错之前看见它打算怎么做),以及**进度有了一个与具体工具无关的度量**(第三题会用到)。
- 代价必须主动说,这是这题的分水岭:**每一次更新清单都是一次工具往返。** 一张三条的清单光维护它就可能多花三四轮,而每一轮都要把整个消息数组重发一次。所以这个功能有明确的适用边界——三步以内的活儿不要用清单,加了就是把一件三十秒的事变成五轮往返。反过来两种情况价值最大:步骤之间有依赖且中间会失败(失败要退回上一步,有清单才知道退到哪),以及任务长到会跨过一次上下文压缩(清单用几十个 token 保住了「整件事到哪了」)。
- 还要讲一个具体的设计取舍来证明你真做过:**状态只留三种(待做、进行中、已完成)。** 加第四种「阻塞」看着更完备,实际是给模型一个体面的逃跑出口——一条做不下去的任务标成阻塞就能名正言顺地绕过去,而清单上看起来一切正常。只有三种状态时,它做不下去就只能说出来,而说出来你才能帮它。
- 可预期的追问:清单要不要落盘?不要。它是这一次任务的工作面,不是事实也不是过程;任务结束就没有意义了,存下来只会让下一次对话看到一份过期的待办,然后先花一轮判断这份待办还算不算数。
Key points
- The mechanism: the model has no memory, so the list externalizes the plan into readable context text
- Do not decompose for it: our steps diverge from its execution, leaving a wrong map it cannot edit
- Extra upside: planning becomes an observable write, and progress gains a tool-agnostic metric
- Cost: every update is a round trip, so skip lists under three steps; they pay off on dependent, failure-prone, long tasks
- Only three states: a fourth blocked state is an escape hatch; the list is ephemeral and not persisted
答题要点
- 机制理由:模型没有记忆,清单把计划外化成上下文里可读的一段文字
- 不该我们替它拆:我们拆的步骤会与它的执行对不上,清单会变成一份它无权修改的错地图
- 额外收益:规划变成可观测的写操作;进度有了与具体工具无关的度量
- 代价:每次更新都是一次工具往返,所以三步以内不用清单;依赖多、会失败、会跨压缩的任务价值最大
- 状态只留三种:第四种「阻塞」是给模型的逃跑出口;清单易失不落盘
With streaming text and a fixed progress area in the same terminal, how do you manage the cursor and repaints?终端里同时有流式文本和固定的进度区域,你怎么管光标与刷新?
Common in ChinaCommon overseasIntermediate#terminal-rendering#cursor-controlHow to reason about it · think before answering
- This one is hard to fake, because its pitfalls only show up once you have written it. There are three levels of signal: naming the real conflict, giving an implementable scheme, and volunteering the degradation path.
- How to break it down: the conflict is that a terminal has one cursor and two things now want it. Streaming text keeps growing downward while the progress area must stay put. Print the progress directly and the next chunk pushes it up, so within seconds the screen holds a dozen stale copies.
- The first decision is to put the panel at the bottom, not the top. It is counterintuitive but firm: the top requires knowing how many lines the body has scrolled, and the body soft-wraps, so its line count depends on terminal width — you would need the window width and would have to handle the user resizing mid-run. The bottom only requires knowing how many lines the panel itself has, which is the one number you actually know.
- The scheme is four actions: draw (save the cursor, newline, paint the panel, leaving the cursor at its end); erase (restore to the saved position, then clear from there to the end of the screen — the panel is gone and the half-written body line survives); write body text (sandwiched between erase and draw); and repaint on change by erasing and drawing again. Use the DEC save/restore cursor rather than counting lines: there is only one slot but you only need one, and it naturally handles which column the half-written line stopped at, a number you cannot compute.
- Then the bonus: make this a wrapper function rather than scattering it through render code. Take a write-string function in, hand a hide/show-sandwiched one back. Done right, the render layer needs no changes at all — it never learns there is a panel below. That is what layering buys, as opposed to vague talk about cleaner code.
- Volunteer the degradation path: a non-TTY destination (a pipe, CI, another agent's shell) has no usable cursor, so emit no cursor control sequences at all and degrade to reprinting the block whenever it changes. The test is the isTTY flag. Emitting control sequences into a pipe turns them into garbage, which is worse than not having the feature — and it is exactly why acceptance must be a human looking at the screen rather than an exit code.
- Likely follow-up: why not use the alternate screen buffer? Because the session scrollback is then gone and the user cannot scroll back to the previous task's output. A CLI taking over the whole screen costs far more than it gains; consider it only for a genuine full-screen TUI.
分析过程 · 先想清楚再作答
- 这题很难靠背答案过,因为它的坑只有动手写过才知道。区分度有三层:能不能说清冲突的本质、能不能给出一个可实现的方案、能不能主动讲退化路径。
- 怎么拆:先说冲突的本质——**一个终端只有一条光标,而现在有两个东西要用它。** 流式文本一直在往下长,进度区域要待在一个不动的地方。直接把进度打出去,下一片文本就会把它挤上去,几秒之后屏幕上是十几个不同版本的进度。
- 第一个决定是**面板放底部,不是顶部**。这一条反直觉但很硬:顶部要算「正文已经滚了多少行」,而正文会自动换行,行数由终端宽度决定——你得知道用户窗口有多宽,还得处理他中途拉窗口。**底部只需要知道面板自己有几行,这是唯一一个你真的知道的数字。**
- 方案就是四个动作:画出来(保存光标位置 → 换行 → 画面板,光标停在面板末尾);擦掉(恢复到保存的位置 → 清掉从这里到屏幕末尾的一切,面板没了而正文那半行还在);写正文(夹在擦掉与画出来之间);内容变了就擦掉再画一次。用 DEC 的保存/恢复光标而不是自己数行,是因为它只有一个槽位但你也只需要一个,而且它天然处理了「正文那半行停在第几列」这个你算不出来的问题。
- 然后是这题的加分项:**把这一层做成一个包装函数,而不是散在渲染代码里。** 拿一个「写字符串」的函数进来,还一个夹了 hide/show 的函数出去。做对之后渲染层一行都不用改——它完全不知道屏幕下方有块面板。这就是分层的价值,而不是「代码更整洁」这种空话。
- 退化路径必须主动说:**非 TTY(管道、CI、别的 Agent 的 shell)没有光标可用,就一个光标控制符都不发**,退化成「内容变了就整块打一遍」。判据是 `isTTY`。把控制符发出去让它变成一串乱码,比不做这个功能更糟——而这正是「验收判据是肉眼看到现象、而不是 exit code」的原因。
- 可预期的追问:为什么不用 alt-screen(整屏接管)?因为终端会话的历史就没了,用户翻不回上一个任务的输出;一个 CLI 工具占掉整屏,代价远大于收益。真要做全屏 TUI 才考虑它。
Key points
- The conflict: one cursor, with streaming text growing downward and a panel that must stay put
- Put the panel at the bottom: the top needs the body's scrolled line count, which soft-wrapping makes width-dependent
- Four actions: save cursor and paint, restore cursor and clear below, write body in between, repaint on change
- Make hide/show a wrapper function so the render layer needs no changes and never learns the panel exists
- On a non-TTY emit no control sequences and reprint the block; the test is isTTY, and acceptance is visual
答题要点
- 冲突本质:一个终端一条光标,流式文本往下长而面板要不动
- 面板放底部:顶部要算正文滚了多少行,而软换行让行数取决于窗口宽度;底部只需知道面板自己几行
- 四个动作:保存光标画面板、恢复光标清到屏幕末尾、正文夹在中间写、变化时擦掉重画
- 把 hide/show 做成一个包装函数,渲染层一行都不用改,也不知道面板存在
- 非 TTY 一个控制符都不发,退化成整块重打;判据是 isTTY,验收靠肉眼看现象
How do you detect from runtime data that an agent is spinning in place, and what do you do once you detect it?怎么从运行数据里发现 Agent 已经在打转?发现之后怎么干预?
Common in ChinaCommon overseasDeep dive#stall-detection#agent-observabilityHow to reason about it · think before answering
- The easy half-answer is detect repeated calls. That is one kind of spinning, but the easiest to catch and the less common one. The signal is naming the other shape and explaining why the first detector misses it.
- How to break it down: two shapes. Mechanical repetition — calling the same tool with byte-identical arguments over and over. And circling in place — doing something different every round while finishing nothing. For the first, key on tool name plus raw argument text and interrupt once consecutive hits reach a threshold. Three rulings matter: compare raw arguments, not meaning (a one-space difference means it is at least trying something new); count only consecutive hits (an intervening different call resets, because read, edit, read again to confirm is a healthy rhythm); and interrupt by feeding a result back, not by escalating to the user.
- The second shape is the common and hard one in long tasks: it read A, then B, then C, arguments differ every time, the repetition detector never fires, and it is still exactly where it started. It is hard to spot because it looks busy the whole time.
- So you need a tool-agnostic progress metric, and a task list supplies one: how many rounds the list's revision number has not changed. Add a more serious signal — how many times a single item has been reopened from done, which means it thought it was finished and then found it was not; twice in a row is close to proof of circling. Those two signals plus the round counter you already keep for resource limits are enough; no new instrumentation required.
- Intervene in two stages, giving it a chance before escalating: on the first hit, feed back a reminder (the list has not moved for N rounds, update it or state where you are stuck) and let the round continue; only on the second hit stop the turn, through the same exit as the hard limits. Two implementation details are worth mentioning: feed the reminder as a user-role message, not assistant — an assistant message reads to the model as something it said itself, so it continues the same line of thought, while a user message reads as someone prodding it. And reuse the existing retryable-error signal rather than adding a new event type, since all the render layer needs to know is that the loop will go around again.
- The real deep end is the cost of false positives. Stall detection needs an exemption: do not judge staleness when the list has never been written at all. Without it, any short conversation that legitimately skips the list gets nagged by round three, and users quickly learn to ignore every warning. A guard that misfires is no guard, and that judgment matters more than the exact threshold.
- Likely follow-up: do the three hard limits — rounds, wall time, tokens — count as spin detection? No, they are resource backstops. Limits watch how much you spent; stall detection watches whether you moved. A task that burns its token budget in five rounds and a task that spins for eight rounds doing nothing are different failures and each needs its own gate.
分析过程 · 先想清楚再作答
- 这题最容易只答一半:「检测重复调用」。那确实是一种打转,但它是最容易抓也最不常见的那一种。区分度在于你能不能说出另一种形态,以及为什么第一种检测抓不到它。
- 怎么拆:把打转分成两种形态。**机械重复**——一字不差地反复调同一个工具同一个参数;**原地兜圈**——每一轮都在做不一样的事,但一件都没做完。第一种用「工具名 + 参数原文」当键,连续命中到阈值就打断,实现很朴素;关键是三条口径:比参数原文不比语义(只差一个空格就算在尝试新东西),只看连续(中间插过别的调用就重新计数,因为「读了改了再读一次确认」是正常节奏),以及打断的方式是回灌而不是抬头。
- 第二种才是长任务里最常见、也最难发现的:读了 A 又读了 B 又读了 C,参数每次都不同,重复检测一次都不会响,而它其实一直在原地。它最难发现的原因是**它看起来一直很忙**。
- 所以你需要一个**与具体工具无关的进展度量**,而任务清单正好提供了一个:清单的版本号多少轮没变。再加一个更严重的信号:同一条任务被从「已完成」重新打开了几次——它的意思是「它以为做完了,又发现没做完」,连续两次基本可以断定在兜圈子。两个信号加上轮数(本来就有的资源记账)就够了,不需要新的埋点。
- 干预分两级,**先给机会再抬头**:第一次命中回灌一条提醒(清单多少轮没动、请更新清单或说清卡在哪),这一轮照常继续;第二次命中才停下这一轮,走和硬上限完全一样的出口。两个实现细节值得讲:提醒要用 **user 角色**回灌而不是 assistant——assistant 消息会被它当成自己说过的话,于是它顺着原思路继续;user 消息才是「有人在催」。以及提醒事件复用现有的「可重试错误」那一位,**不给事件协议加新类型**,因为渲染层要知道的只是「循环接下来还会转一圈」。
- 最后是这题真正的深水区——**假警报的成本**。停滞检测必须有一条豁免:清单一次都没被写过时不判停滞。少了它,任何一次不用清单的短对话转到第三轮都会被提醒,而用户很快就学会无视所有提醒了。**一个会误报的守卫等于没有守卫**,这条判断比阈值调多少更重要。
- 可预期的追问:那三条硬上限(轮数、时长、token)算不算打转检测?不算,它们是资源兜底:**上限看的是花了多少,停滞看的是有没有进展。** 一个五轮就把 token 烧光的任务和一个转了八轮啥也没干的任务,是两种不同的失控,各需要一条闸。
Key points
- Two shapes: mechanical repetition (same tool, same arguments) and circling (different each round, no progress)
- Three rulings for repetition: compare raw arguments, count only consecutive hits, interrupt by feeding back
- Circling needs a tool-agnostic progress metric: rounds since the list's revision changed, plus reopen counts
- Two-stage intervention: feed back a reminder first (as a user-role message, not assistant), stop only on the second hit
- An exemption is mandatory: never judge staleness when the list was never used — a guard that misfires is no guard
答题要点
- 打转有两种形态:机械重复(同工具同参数)与原地兜圈(每轮都不同但没进展)
- 重复检测的三条口径:比参数原文、只看连续、打断方式是回灌而不是抬头
- 兜圈要靠与工具无关的进展度量:清单版本号多少轮没变,加上同一条被重开几次
- 干预两级:先回灌提醒(用 user 角色,不用 assistant),再命中才停下这一轮
- 必须有豁免:清单没被用过就不判停滞——会误报的守卫等于没有守卫