Skip to content

Commit f14c96a

Browse files
committed
Announce what is about to happen, so the status line stops guessing
Reported: during the LLM generation the status line said "正在判定 · 已 等待 9s". The label was wrong for the whole generation. Every event was a record of **finished** work - `decision` carries latencyMs, `generate` carries token counts. So the UI could only infer "what is happening now" from "what finished last", and that inference is wrong for longest exactly where it matters most: decision(isDone -> finish) label becomes "正在判定" ... two seconds of generation, label still "正在判定" ... generate label becomes "正在生成回答" (already over) decision(canDeliver) label becomes "正在判定" (a flash later) `tool:call` was already emitted before the tool ran, which is why the tool phase was always right. Decisions and generation were missing their "starting" half. ## The event | { type: 'phase'; step: number; kind: 'decide' | 'generate'; id?: string } `id` carries the decision node, so "which decision is slow" is answerable from the stream. It deliberately does **not** go into the UI's `EVENT_META` table: that table describes finished work, and every row in it has a duration and a lane. A start has neither, and would only add an empty row. The trace already guards on `EVENT_META`, so the event stays out of the ledger and the timeline on its own. In `agent.ts` the eight `record(await decider.decide(...))` call sites are now one `ask(spec, ctx)` helper, which emits the phase and then records. `resolveInput` used to receive `decider` and `record` separately, which is why it could not have announced anything even if someone had remembered - it receives `ask` now, so there is one way to ask and one place to emit. The two `generator.generate` calls announce before they run. ## The UI `setWaiting` did two jobs, and they answer different questions, so they are two functions now: `setPhase` changes the label (called only by start signals - `phase` and `tool:call`), `touch` restarts the seconds counter (called by anything). Only start signals change the label; that is the fix. The stall threshold is per phase rather than one number for all three. Hosted Jev is ~390ms per decision while a generation starts at two seconds and can run for tens; a single 6s both called normal generations slow and was far too patient with a stuck decision. Now 6s for decisions and tools, 30s for generation - both derived from measured numbers, and the generation one stays inside the generator's own 60s timeout, which is why the wording is "longer than usual" and not "failed". ## Verification Three tests: every `decision` and `generate` must be immediately preceded by its matching `phase`, and the phase must carry a real node id. In a real browser, sampling the status line every 120ms, the check that matters is latency-independent: **while the label says it is generating, the `generate` event must not have arrived yet** - 11 to 14 samples per run, 0 out of place. Reverting to the old behaviour fails it. Two latency-dependent assertions I wrote first were removed: one assumed a decision never exceeds 5s (the first hosted call took 5.8s) and one assumed generation exceeds 1.5s (one run took 1.2s). Both were flaky, and a check that fails on a slow machine is not a check. Verified: tsc clean, check 44 files, 161/161 tests, demo `agent_done` at 14:1, browser 4/4 across three consecutive runs.
1 parent 7ff6a8b commit f14c96a

4 files changed

Lines changed: 212 additions & 19 deletions

File tree

‎src/agent.ts‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
*/
3535

3636
import { Decider } from './decide.ts'
37-
import type { DecisionResult } from './vocab-decision.ts'
37+
import type { DecisionSpec, DecisionResult } from './vocab-decision.ts'
38+
import type { AnswerMap, QuestionSet } from './vocab.ts'
3839
import { Meter } from './meter.ts'
3940
import { clip } from './budget.ts'
4041
import {
@@ -103,6 +104,19 @@ export interface AgentOptions {
103104
onEvent?: AgentObserver
104105
}
105106

107+
/**
108+
* 「问一次判定」的形状。
109+
*
110+
* 抽出来是为了让 `resolveInput` 也拿到**同一个**入口 —— 它原本收的是
111+
* `decider` 和 `record` 两个参数,于是「播报 phase」这件事在那个函数里
112+
* 根本做不到(它够不到 `runAgent` 的闭包)。只传一个 `ask` 之后,
113+
* 「问判定」只有一种写法,漏掉播报是不可能的。
114+
*/
115+
type Ask = <C, Q extends QuestionSet>(
116+
spec: DecisionSpec<C, Q>,
117+
ctx: C,
118+
) => Promise<DecisionResult<AnswerMap<Q>>>
119+
106120
export interface AgentResult {
107121
answer: string
108122
/** 停机原因 */
@@ -247,21 +261,42 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
247261
return d
248262
}
249263

264+
/**
265+
* 问一次判定 —— **先播报「要问了」,再问**。
266+
*
267+
* ★ 两件事的顺序是重点,不是风格。`decision` 事件是**做完之后**才发的
268+
* (它带着 `latencyMs` 和答案),所以只靠它,观察者只能拿「上一次干完
269+
* 的是什么」去猜「现在在干什么」—— 而它在**最长的那一步上错得最久**。
270+
*
271+
* 实测(2026-09-21,用户报的):生成那 2 秒里界面显示「正在判定」,
272+
* 因为最后一条事件是 `isDone`。`tool:call` 本来就在跑之前发,所以
273+
* 工具段一直是对的;判定段和生成段缺「开始」,这里补上。
274+
*
275+
* 顺带把 `record` 收进来:每个判定点都必须过这一道,**漏一处界面就少
276+
* 一个决策点**,而那种缺失不会报错(`record` 的注释也是这么写的)。
277+
* 合成一个函数之后,「忘了发 phase」和「忘了记 decision」变成同一个
278+
* 错误 —— 只有一种写法。
279+
*/
280+
const ask: Ask = async (spec, c) => {
281+
emit({ type: 'phase', step, kind: 'decide', id: spec.id })
282+
return record(await decider.decide(spec, c))
283+
}
284+
250285
// ── 工具循环 ──────────────────────────────────────────────
251286
while (step < maxSteps) {
252287
step++
253288
decider.setStep(step)
254289

255290
// ↗ 需要动手吗
256-
const need = record(await decider.decide(needsTool, ctx))
291+
const need = await ask(needsTool, ctx)
257292
if (need.action === 'answer') {
258293
halt = 'answered_directly'
259294
trace(` answering directly (no tool needed)`)
260295
break
261296
}
262297

263298
// ↗ 用哪个工具(候选每步重建)
264-
const pick = record(await decider.decide(pickTool, ctx))
299+
const pick = await ask(pickTool, ctx)
265300
if (pick.escalate || pick.action !== 'call') {
266301
halt = 'tool_unclear'
267302
trace(` tool choice unclear → stopping (${pick.reason})`)
@@ -289,7 +324,7 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
289324
ctx.lastTool = tool
290325

291326
// 工具参数是一次**判定**,不是写死的代码(审计 N3)
292-
const input = await resolveInput(tool, ctx, decider, record, opts.provideWriteContent)
327+
const input = await resolveInput(tool, ctx, ask, opts.provideWriteContent)
293328
if (input === undefined) {
294329
halt = 'input_unclear'
295330
trace(` could not choose an input for ${tool} → stopping`)
@@ -300,7 +335,7 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
300335
ctx.history = [...(ctx.history ?? []), pending]
301336

302337
// ↗ 这个操作多危险
303-
const risk = record(await decider.decide(gradeRisk, ctx))
338+
const risk = await ask(gradeRisk, ctx)
304339
if (risk.escalate || risk.action === 'ask_human') {
305340
const approved = opts.onAskHuman ? await opts.onAskHuman(risk.reason, tool) : false
306341
emit({ type: 'authorize', step, tool, reason: risk.reason, approved })
@@ -351,15 +386,15 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
351386
}
352387

353388
// ↗ 成功了吗
354-
const ok = record(await decider.decide(stepOk, ctx))
389+
const ok = await ask(stepOk, ctx)
355390
if (ok.action !== 'continue') {
356391
halt = 'step_failed'
357392
trace(` this step did not succeed → stopping (${ok.reason})`)
358393
break
359394
}
360395

361396
// ↗ 做完了吗
362-
const done = record(await decider.decide(isDone, ctx))
397+
const done = await ask(isDone, ctx)
363398
if (done.action === 'finish') {
364399
halt = 'task_done'
365400
break
@@ -454,6 +489,7 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
454489

455490
let genStep = step + 1
456491
decider.setStep(genStep)
492+
emit({ type: 'phase', step: genStep, kind: 'generate' })
457493
const gen = await generator.generate({
458494
task: ctx.task,
459495
evidence: evidenceText,
@@ -484,7 +520,7 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
484520
ctx.draft = gen.text
485521

486522
// ↗ 能交付吗
487-
let deliver = record(await decider.decide(canDeliver, ctx))
523+
let deliver = await ask(canDeliver, ctx)
488524

489525
// `revise` 承诺了「修订」,那修订就必须真的发生 ——
490526
// 以前它只是被拼进 halt 字符串,草稿原样返回。
@@ -493,6 +529,7 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
493529
trace(` the delivery gate asked for a revision (${deliver.reason}) → regenerating once with that feedback`)
494530
genStep += 1
495531
decider.setStep(genStep)
532+
emit({ type: 'phase', step: genStep, kind: 'generate' })
496533
const retry = await generator.generate({
497534
task: ctx.task,
498535
// 同一份证据和同一份上文 —— 循环早就结束了,两者都没变过
@@ -517,7 +554,7 @@ export async function runAgent(opts: AgentOptions): Promise<AgentResult> {
517554
estimatedInputTokens: evidenceEstimate.controlTokens,
518555
})
519556
ctx.draft = retry.text
520-
deliver = record(await decider.decide(canDeliver, ctx))
557+
deliver = await ask(canDeliver, ctx)
521558
}
522559

523560
if (deliver.action !== 'deliver') {
@@ -647,15 +684,14 @@ function noteForConversation(r: ConversationReport): string {
647684
*
648685
* @param tool 已经过 `isToolName` 校验的工具名
649686
* @param ctx 当前上下文,`pickInput` 的候选从这里构造
650-
* @param decider 判定器
687+
* @param ask 问一次判定的唯一入口(见 `Ask`)—— 它负责播报 phase 和记 decision
651688
* @param writeContent `write_file` 的内容来源,见 `AgentOptions.provideWriteContent`
652689
* @returns 工具的输入字符串;无法确定时返回 `undefined`(调用方应停机,不要猜)
653690
*/
654691
async function resolveInput(
655692
tool: ToolName,
656693
ctx: AgentCtx,
657-
decider: Decider,
658-
record: <A>(d: DecisionResult<A>) => DecisionResult<A>,
694+
ask: Ask,
659695
writeContent?: AgentOptions['provideWriteContent'],
660696
): Promise<string | undefined> {
661697
switch (tool) {
@@ -668,7 +704,7 @@ async function resolveInput(
668704
case 'write_file': {
669705
// 没有候选就**不要问** —— criteria 为空的 choice 是无效问题
670706
if (!hasFileOptions(ctx)) return undefined
671-
const d = record(await decider.decide(pickInput, ctx))
707+
const d = await ask(pickInput, ctx)
672708
if (d.escalate || d.action !== 'use') return undefined
673709
const file = d.answers.file.choice
674710
if (tool === 'read_file') return file

‎src/events.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,37 @@ export type AgentEvent =
3535
degraded: boolean
3636
escalate: boolean
3737
}
38+
/**
39+
* 一步操作**开始了**。
40+
*
41+
* ══════════════════════════════════════════════════════════════
42+
* ★ 为什么需要它:**上面所有事件都是「做完之后」才发的。**
43+
* ══════════════════════════════════════════════════════════════
44+
*
45+
* `decision` 带着 `latencyMs`、`generate` 带着 token 数 —— 它们都是
46+
* **记录**。于是「现在在干什么」这个问题,观察者只能拿「上一次干完的是
47+
* 什么」去猜,而这个猜法在**最长的那一步上错得最久**。
48+
*
49+
* 实测(2026-09-21,用户报的):生成要 2 秒以上,那段时间界面显示的
50+
* 是「正在判定」—— 因为最后一条事件是 `isDone` 判定。等 `generate`
51+
* 到了,它把标签改成「正在生成回答」,可那时生成**已经结束**了,
52+
* 而且下一条 `canDeliver` 立刻又改回「正在判定」。
53+
*
54+
* `tool:call` 本来就在跑之前发,所以工具那一段一直是对的。判定和生成
55+
* 这两段缺「开始」,补上之后三段形状一致。
56+
*
57+
* ⚠️ **它刻意不进 `EVENT_META`**(界面的那张分类表):那一张描述的是
58+
* **做完的事**,每一行都有耗时和泳道;「开始」没有耗时,进去只会多出
59+
* 一行空记录。界面在 `onEvent` 里单独处理它。
60+
*/
61+
| {
62+
type: 'phase'
63+
step: number
64+
/** 这一段在干什么 */
65+
kind: 'decide' | 'generate'
66+
/** `kind: 'decide'` 时是判定节点 id,如 `loop.pickTool` */
67+
id?: string
68+
}
3869
/** 判定节点要求人工授权,以及结果 */
3970
| { type: 'authorize'; step: number; tool: string; reason: string; approved: boolean }
4071
/** `auto_audit` 承诺的留痕真的发生了 */

‎tests/core.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,3 +785,92 @@ test('★ stepOk 的问题与判据都在说「这次调用」,不是「这个
785785
assert.match(q.instructions, /this tool call/i, '要明说判的是这次调用')
786786
assert.match(q.instructions, /decided elsewhere/i, '要明说任务完成度在别处判')
787787
})
788+
789+
// ═══════════════════════════════════════════════════════════
790+
// phase 事件:界面显示「现在在干什么」的唯一依据
791+
//
792+
// ★ 起因是一个真 bug(2026-09-21,用户报的):生成那 2 秒里界面显示的是
793+
// 「正在判定」。因为所有事件都是**做完之后**才发的(`decision` 带
794+
// latencyMs、`generate` 带 token 数),界面只能拿「上一次干完的是什么」
795+
// 去猜,而这个猜法在最长的那一步上错得最久。
796+
// ═══════════════════════════════════════════════════════════
797+
798+
test('★ 每个 decision / generate **之前**都有一条 phase —— 界面靠它显示当前阶段', async () => {
799+
const { runAgent } = await import('../src/agent.ts')
800+
const { Decider } = await import('../src/decide.ts')
801+
const { Meter } = await import('../src/meter.ts')
802+
803+
const seq: string[] = []
804+
const decider = new Decider({
805+
meter: new Meter(),
806+
provider: {
807+
name: 'spy',
808+
decide: async () => ({ answers: { needs_tool: { type: 'noul', noul: 0.1 } }, provider: 'spy', latencyMs: 0 }),
809+
} as never,
810+
})
811+
812+
await runAgent({
813+
task: 'T',
814+
cwd: '/tmp',
815+
decider,
816+
generator: {
817+
name: 'capture',
818+
generate: async () => ({ text: 'ok', latencyMs: 0, inputTokens: 0, outputTokens: 0, model: 'capture' }),
819+
},
820+
maxSteps: 1,
821+
onEvent: (e) => {
822+
seq.push(e.type === 'phase' ? `phase:${e.kind}` : e.type)
823+
},
824+
})
825+
826+
// 走一遍序列:每个 decision / generate 前面**紧邻**的必须是对应的 phase
827+
const missing: string[] = []
828+
for (const [i, x] of seq.entries()) {
829+
if (x === 'decision' && seq[i - 1] !== 'phase:decide') missing.push(`第 ${i} 条 decision 前面是 ${seq[i - 1]}`)
830+
if (x === 'generate' && seq[i - 1] !== 'phase:generate') missing.push(`第 ${i} 条 generate 前面是 ${seq[i - 1]}`)
831+
}
832+
assert.deepEqual(missing, [], '有操作没有先播报 phase —— 界面会在那一段显示上一个阶段的标签')
833+
834+
// 防呆:别因为一次判定都没发生而静默通过
835+
assert.ok(seq.filter((x) => x === 'phase:decide').length >= 1, `一次判定都没发生:${seq.join(' → ')}`)
836+
assert.ok(seq.includes('phase:generate'), `没有生成阶段:${seq.join(' → ')}`)
837+
})
838+
839+
test('phase 事件带得上判定节点 id —— 排查「哪个判定慢」要看它', async () => {
840+
const { runAgent } = await import('../src/agent.ts')
841+
const { Decider } = await import('../src/decide.ts')
842+
const { Meter } = await import('../src/meter.ts')
843+
844+
const ids: string[] = []
845+
const decider = new Decider({
846+
meter: new Meter(),
847+
provider: {
848+
name: 'spy',
849+
decide: async () => ({ answers: { needs_tool: { type: 'noul', noul: 0.1 } }, provider: 'spy', latencyMs: 0 }),
850+
} as never,
851+
})
852+
await runAgent({
853+
task: 'T',
854+
cwd: '/tmp',
855+
decider,
856+
generator: {
857+
name: 'capture',
858+
generate: async () => ({ text: 'ok', latencyMs: 0, inputTokens: 0, outputTokens: 0, model: 'capture' }),
859+
},
860+
maxSteps: 1,
861+
onEvent: (e) => {
862+
if (e.type === 'phase' && e.kind === 'decide') ids.push(e.id ?? '(没有 id)')
863+
},
864+
})
865+
// ⚠️ 不要写成 `['loop.needsTool']` —— spy provider 对**每个**判定都返回
866+
// 同一份答案,所以 `canDeliver` 也拿到 `needs_tool:0.1`,它的策略在缺
867+
// `deliverable` / `unsupported` 时走到 `revise`,于是**又一次生成 +
868+
// 又一次 canDeliver**。实测序列是 needsTool → canDeliver → canDeliver。
869+
// (同一个坑今天绊了两次:修订那条路径让「一次运行有几次生成」比
870+
// 直觉多一次。)
871+
assert.equal(ids[0], 'loop.needsTool', '第一个判定是 needsTool')
872+
assert.ok(
873+
ids.every((id) => /^loop\.[a-zA-Z]+$/.test(id)),
874+
`每个 phase 都要带真实的节点 id,实际拿到:${JSON.stringify(ids)}`,
875+
)
876+
})

‎web/app.js‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,18 @@ function processRow(e, dur) {
419419
* 没有这个,一次 37 秒的运行看起来和挂掉没有区别 —— 而它其实一直在跑,
420420
* 只是每次判定之间隔着几百毫秒的静默。
421421
*/
422-
const STALL_AFTER_MS = 6000
422+
/**
423+
* 「比平时久」的界线,**按阶段分开**。
424+
*
425+
* ★ 一个数管三段是错的:托管 Jev 一次判定约 **390ms**(§8.11),而一次
426+
* 生成 **2 秒起、几十秒都正常**(生成器自己的超时是 60 秒)。用同一个
427+
* 6 秒,既会把正常的生成报成「比平时久」,又对真卡住的判定太宽容。
428+
*
429+
* 数字的来源:判定 6 秒 ≈ 托管 Jev 的 15 倍;生成 30 秒 ≈ 实测常见的
430+
* 10 倍,同时留在 60 秒超时之内 —— 超过它多半是真出问题了,但**还可能
431+
* 正常返回**,所以文案是「比平时久」而不是「失败」。
432+
*/
433+
const STALL_AFTER_MS = { decide: 6000, tool: 6000, generate: 30000 }
423434
let ticker = null
424435

425436
function startTicker() {
@@ -428,7 +439,7 @@ function startTicker() {
428439
if (!current || current.finished || current.stopped) return
429440
const waited = Date.now() - (current.lastEventAt ?? Date.now())
430441
const secs = (waited / 1000).toFixed(0)
431-
const stalled = waited >= STALL_AFTER_MS
442+
const stalled = waited >= (STALL_AFTER_MS[current.phaseKind] ?? 6000)
432443
current.running.className = `running-row${stalled ? ' stalled' : ''}`
433444
current.running.replaceChildren(
434445
h('span', { class: 'pulse' }),
@@ -444,16 +455,38 @@ function stopTicker() {
444455
}
445456
}
446457

447-
function setWaiting(text) {
458+
/**
459+
* 换「现在在干什么」,并把秒数从头数起。
460+
*
461+
* **只有「开始」信号该调它** —— `phase` 事件和 `tool:call`。其余事件都是
462+
* 做完之后才发的,拿它们改标签就是原来那个 bug(生成期间显示「正在判定」)。
463+
*/
464+
function setPhase(text, kind) {
448465
if (!current) return
449466
current.waitingOn = text
467+
current.phaseKind = kind
450468
current.lastEventAt = Date.now()
451469
if (!current.stopped) {
452470
current.running.className = 'running-row'
453471
current.running.replaceChildren(h('span', { class: 'pulse' }), `${text} · 已等待 0s`)
454472
}
455473
}
456474

475+
/**
476+
* 又来了一条事件 —— **只把秒数从头数起,不动标签**。
477+
*
478+
* 两件事分开是因为它们回答不同的问题:标签是「在做什么」(由开始信号决定),
479+
* 秒数是「上一次有动静是多久以前」(任何事件都算)。
480+
*/
481+
function touch() {
482+
if (current) current.lastEventAt = Date.now()
483+
}
484+
485+
/** `phase` 事件 → 那句话。判定节点 id 不进去 —— 轨迹里有,这里加只会变吵 */
486+
function phaseText(e) {
487+
return e.kind === 'generate' ? '正在生成回答' : '正在判定'
488+
}
489+
457490
/**
458491
* 「判定 : 模型」的显示文本。**两个调用点共用这一个函数。**
459492
*
@@ -1100,9 +1133,13 @@ function onEvent(e) {
11001133
followTail()
11011134
syncTurnRail()
11021135
}
1103-
if (e.type === 'tool:call') setWaiting(`正在执行 ${e.tool}`)
1104-
else if (e.type === 'generate') setWaiting('正在生成回答')
1105-
else setWaiting('正在判定')
1136+
// ★ 「现在在干什么」**只由开始信号决定**:`phase` 和 `tool:call`。
1137+
// 其余事件(`decision` / `generate` / `tool:result`)都是**做完之后**
1138+
// 才发的,拿它们猜就会在最长的那一步上错得最久 —— 实测生成那 2 秒
1139+
// 显示的是「正在判定」,因为最后一条事件是 `isDone`。
1140+
if (e.type === 'phase') setPhase(phaseText(e), e.kind)
1141+
else if (e.type === 'tool:call') setPhase(`正在执行 ${e.tool}`, 'tool')
1142+
else touch()
11061143
}
11071144

11081145
// 轨迹侧:账本加一行,时间轴加一条

0 commit comments

Comments
 (0)