From f5fd1ed1536fc34c54862b569fbd26905ce2a6f1 Mon Sep 17 00:00:00 2001 From: Phant Date: Mon, 5 Oct 2026 19:00:18 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat(core):=20=E4=B8=BB=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E8=BF=90=E8=A1=8C=E9=98=B6=E6=AE=B5=20RunPha?= =?UTF-8?q?se,=E5=8F=AF=E8=A7=81=20World=20=E7=BB=8F=20onRunPhase=20?= =?UTF-8?q?=E6=8E=A5=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunPhase 的 state 为 idle、delivering、model、tools、backoff、handoff,带本批轮序号、 执行中的工具名(含流式提前执行的调用)、backoff 的 retryAt 与进入时刻;getStatus().phase 返回当前值。 - state 或 round 改变、工具开始或结束时,同步通知可见 World 的 onRunPhase 与 MainLoop.onRunPhase 的订阅,异常记 warn。 - 一批的轮次结束即回到 idle,onBatchEnd、onIdle 在 idle 下运行。暂停与投递闸门不并入, 仍由 paused、scheduleBlocked 报告。 - 测试覆盖带工具调用一批的阶段序列、隐藏 World 不收通知,以及 backoff 的 retryAt 与重试时刻。 Co-Authored-By: Claude Opus 5.5 --- docs/world-compatibility.md | 1 + docs/worlds.md | 1 + src/core/README.md | 6 ++ src/core/loop.ts | 107 ++++++++++++++++++++++++++++++++---- src/core/types.ts | 23 ++++++++ tests/core/loop.test.ts | 60 +++++++++++++++++++- 6 files changed, 187 insertions(+), 11 deletions(-) diff --git a/docs/world-compatibility.md b/docs/world-compatibility.md index e9c73258..4c9d47a6 100644 --- a/docs/world-compatibility.md +++ b/docs/world-compatibility.md @@ -44,6 +44,7 @@ - `outputTap()`:把主 session 的模型输出流逐段交给 World;`externalizes` 返回 true 后,该轮不再被 `preempt` 取消。 - `cognition`:World 向 Persona 请求后台认知计算。 - `llmStalls(withinMs)`:最近一段时间内模型调用失败或流中断的次数。 +- `onRunPhase(phase)`:主循环进入投递、模型调用、工具执行、重试等待、交接或空闲时,以及工具开始或结束时同步通知。 控制台面板与配置组不属于等级;没有控制台的宿主由 World 的配置文件提供配置。 diff --git a/docs/worlds.md b/docs/worlds.md index 7ecec973..ae6f8b59 100644 --- a/docs/worlds.md +++ b/docs/worlds.md @@ -20,6 +20,7 @@ World 不直接访问 Memory 或调用 Persona 的工具。 | `outputTap?()` | 主 session 输出流的接收器(演出、字幕);这一刻没有接收器时返回 `undefined` | | `onHandoffEnded?()`、`onTurnEnded?()` | 交接结束与主循环一轮结束的通知;隐藏的 World 不接收 | | `onEventsSettled?(events, outcome)` | 本 World 的事件写入主 session(`delivered`)或被操作者清空队列丢弃(`discarded`) | +| `onRunPhase?(phase)` | 主循环的 `RunPhase`(`idle`、`delivering`、`model`、`tools`、`backoff`、`handoff`,轮序号,执行中的工具名)改变时同步调用;隐藏的 World 不接收 | | `shutdownVerification?()` | 关机前要核对的外部状态,同步只读快照 | Core 通过 `WorldHost` 向 World 提供以下能力: diff --git a/src/core/README.md b/src/core/README.md index 75d2d497..7233ff67 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -78,6 +78,12 @@ piggyback 只入队,随后续唤醒一起投递。 默认允许连续重试 2 次、每批最多 4 次,退避为 2 秒、10 秒;上下文超限、抢占、关机或轮数 达到硬上限时不重试。 +`MainLoop` 维护 `RunPhase`:`delivering`(写入一批事件,含等待 `onDelivery`)、`model`、`tools`、 +`backoff`(带 `retryAt`)、`handoff`,其余时刻是 `idle`;一批的轮次结束即回到 `idle`,批末钩子在 +`idle` 下运行。`running` 按开始顺序列出执行中的工具,含流式提前执行的调用。state 或 round 改变、 +工具开始或结束时同步通知可见 World 的 `onRunPhase` 与控制台的订阅,异常记 warn;`getStatus().phase` +返回当前值。暂停与投递闸门不进入 `RunPhase`,由 `paused`、`scheduleBlocked` 报告。 + 参数不是合法 JSON 时返回 `TOOL_FAILED_BAD_ARGS`,不执行工具;未知工具返回 `UNKNOWN_TOOL`; handler 异常转为失败回执。流式生成时 `EagerDispatch` 可提前执行完整的工具调用,遵守 `barrierAfter` 顺序,并按 call id 配对结果。回执超过 8000 字符时记录 warn。 diff --git a/src/core/loop.ts b/src/core/loop.ts index 6c769a05..b6dc456c 100644 --- a/src/core/loop.ts +++ b/src/core/loop.ts @@ -29,6 +29,7 @@ import type { ModelSpec, OutputTap, Persona, + RunPhase, SessionDecl, SessionOpeningReason, ToolCallContext, @@ -172,6 +173,7 @@ export interface LoopStatus { lastDeliveredCursor: number; /** 水位之后该进上下文却还没投递的外部事件数。 */ behind: number; + phase: RunPhase; } /** @@ -331,12 +333,69 @@ export class MainLoop { private backoffWake: (() => void) | null = null; /** 关机或循环换代时取消全部工具;interruptible 工具另外合并当前轮的 interrupt 信号。 */ private readonly shutdown = new AbortController(); + private phase: RunPhase; + /** 执行中的工具调用,键为每次调用的登记凭据,按开始顺序。 */ + private readonly runningTools = new Map(); + private readonly phaseListeners: Array<(phase: RunPhase) => void> = []; constructor(deps: MainLoopDeps) { this.d = deps; + this.phase = { state: 'idle', running: [], since: nowIso(deps.cfg.timezone) }; deps.session.onReset(() => { this.anchor = null; }); } + /** 控制台订阅 RunPhase 变化;与 World.onRunPhase 同时、同步调用。 */ + onRunPhase(listener: (phase: RunPhase) => void): void { + this.phaseListeners.push(listener); + } + + /** state、round 或 retryAt 不变时不通知,since 保持进入时的时刻。 */ + private enterPhase(state: RunPhase['state'], detail: { round?: number; retryAt?: string } = {}): void { + const cur = this.phase; + if (cur.state === state && cur.round === detail.round && cur.retryAt === detail.retryAt) return; + this.phase = { + state, + ...(detail.round !== undefined ? { round: detail.round } : {}), + running: [...this.runningTools.values()], + ...(detail.retryAt !== undefined ? { retryAt: detail.retryAt } : {}), + since: nowIso(this.d.cfg.timezone), + }; + this.emitPhase(); + } + + /** 登记一次开始执行的工具调用,返回它结束时调用的函数。 */ + private readonly toolStarted = (name: string): (() => void) => { + const token = {}; + this.runningTools.set(token, name); + this.phase = { ...this.phase, running: [...this.runningTools.values()] }; + this.emitPhase(); + return () => { + if (!this.runningTools.delete(token)) return; + this.phase = { ...this.phase, running: [...this.runningTools.values()] }; + this.emitPhase(); + }; + }; + + private emitPhase(): void { + if (!this.activeNow()) return; + const { phase } = this; + for (const world of this.d.worlds.visible()) { + if (!world.onRunPhase) continue; + try { + world.onRunPhase(phase); + } catch (e) { + this.d.log.warn('World 运行阶段钩子异常', { id: world.id, err: e }); + } + } + for (const listener of this.phaseListeners) { + try { + listener(phase); + } catch (e) { + this.d.log.warn('运行阶段订阅异常', { err: e }); + } + } + } + private active(generation: number): boolean { return !this.stopped && !this.sealed && this.generation === generation; } @@ -531,9 +590,11 @@ export class MainLoop { * 候选按 source、origin 和处理函数分组,再按来源项在批次中的顺序生成正文。 * 正文归档后调用并等待 onDelivery;钩子完成前注入的内部项追加到内部行末尾、外部正文之前。 * 内部行合成一条 user 消息;外部正文按 eventDelivery 进入合成工具回执或同一条 user 消息。 + * round 是批内轮间投递时的当前轮序号。 */ - private async deliverBatch(batch: WakeItem[], generation: number): Promise { + private async deliverBatch(batch: WakeItem[], generation: number, round?: number): Promise { if (!this.active(generation)) return false; + this.enterPhase('delivering', { round }); const { session, persona, store, cfg, log } = this.d; const projections = this.prepareCandidateProjections(batch, generation); if (!this.active(generation)) return false; @@ -943,11 +1004,14 @@ export class MainLoop { if (changed) { await this.rounds(generation); if (!this.active(generation)) break; + this.enterPhase('idle'); // 先执行本批登记的交接请求,再运行批末钩子与容量检查。 await this.flushRequestedHandoff(generation); if (!this.active(generation)) break; await this.batchEndCheck(generation); if (!this.active(generation)) break; + } else { + this.enterPhase('idle'); } // 延迟渲染和 piggyback 项不阻止进入空闲钩子。 @@ -1016,7 +1080,7 @@ export class MainLoop { this.takeReadyBeforeRequest = false; const ready = bus.takeIfReady(); if (ready) { - await this.deliverBatch(ready, generation); + await this.deliverBatch(ready, generation, round); if (!this.active(generation)) return; } } @@ -1039,6 +1103,7 @@ export class MainLoop { externalized: false, interrupted: false, abortReason: null, }; this.currentRound = flight; + this.enterPhase('model', { round }); const ctx: ToolCallContext = { role: decl.id, log, @@ -1058,6 +1123,7 @@ export class MainLoop { (name) => this.d.toolOwner?.(name), flight.interrupt.signal, () => (flight.interrupted ? NOT_EXECUTED_INTERRUPTED : null), + this.toolStarted, ) : null; /** @@ -1073,7 +1139,7 @@ export class MainLoop { this.finishTurn(); return false; } - await this.deliverBatch(arrived, generation); + await this.deliverBatch(arrived, generation, round); return this.active(generation); }; // 轮级观测:首个内容事件的延迟、模型往返、工具阻塞,一轮一条 debug 记录(event=round)。 @@ -1216,12 +1282,13 @@ export class MainLoop { const delayMs = resubmit.backoffMs[Math.min(consecutiveFailures, resubmit.backoffMs.length) - 1] ?? 0; log.warn('LLM 调用失败,退避后在本批内重试', { ...detail, resubmits, delayMs }); noteRound('failed', { resubmit: true, delayMs }); + this.enterPhase('backoff', { round, retryAt: nowIso(this.d.cfg.timezone, new Date(Date.now() + delayMs)) }); await this.backoff(delayMs); if (!this.active(generation)) return; // 重试前先投递等待期间已就绪的事件。 const ready = bus.takeIfReady(); if (ready) { - await this.deliverBatch(ready, generation); + await this.deliverBatch(ready, generation, round); if (!this.active(generation)) return; } continue; @@ -1256,6 +1323,7 @@ export class MainLoop { return; } + this.enterPhase('tools', { round }); const results: ContextRecord[] = []; let barrierHit = false; // endsTurn 工具真正执行过(没被屏障跳过、参数合法)才算数 @@ -1301,7 +1369,7 @@ export class MainLoop { } out = await runToolHandler( def, args, ctx, call.call_id, decl.id, this.d.toolLog, - () => this.active(generation), this.d.toolOwner?.(def.name), flight.interrupt.signal, + () => this.active(generation), this.d.toolOwner?.(def.name), flight.interrupt.signal, this.toolStarted, ); if (!this.active(generation)) return; } @@ -1335,7 +1403,7 @@ export class MainLoop { // 不再执行模型请求时,事件退回总线随下一批投递。 for (const item of arrived) bus.push(item, { trigger: 'flush' }); } else { - await this.deliverBatch(arrived, generation); + await this.deliverBatch(arrived, generation, round); if (!this.active(generation)) return; } } @@ -1382,6 +1450,7 @@ export class MainLoop { const calls = partial.flatMap(entry => entry.item.type === 'function_call' ? [entry.item] : []); this.pendingToolCalls = new Set(calls.map((call) => call.call_id)); for (const entry of partial) session.append(entry); + if (calls.some((call) => eager?.has(call.call_id))) this.enterPhase('tools', { round: this.phase.round }); for (const call of calls) { const ran = eager?.take(call.call_id); const out = ran !== undefined ? await ran : { text: notExecuted }; @@ -1488,7 +1557,14 @@ export class MainLoop { if (!this.active(generation)) return Promise.resolve(); if (this.truncatePromise) return this.truncatePromise; let tracked: Promise; - tracked = this.enqueueMaintenance(() => this.performHandoff(generation), generation).finally(() => { + tracked = this.enqueueMaintenance(async () => { + this.enterPhase('handoff'); + try { + await this.performHandoff(generation); + } finally { + this.enterPhase('idle'); + } + }, generation).finally(() => { if (this.truncatePromise === tracked) this.truncatePromise = null; }); this.truncatePromise = tracked; @@ -1894,6 +1970,7 @@ export class MainLoop { lastUsage: this.lastUsage, lastDeliveredCursor: this.d.state.data.lastDeliveredCursor, behind: this.watermarkBacklog().behind, + phase: this.phase, }; } @@ -1948,7 +2025,10 @@ function parseToolArgs(raw: string): Record | null { } } -/** 普通执行与流式提前执行共用工具处理及日志记录;异常转换为失败回执。 */ +/** + * 普通执行与流式提前执行共用工具处理及日志记录;异常转换为失败回执。 + * started 在 handler 开始时登记调用,返回的函数在回执确定时调用。 + */ function runToolHandler( def: ToolDef, args: Record, @@ -1959,16 +2039,22 @@ function runToolHandler( canRecord: () => boolean = () => true, mod?: string, interrupt?: AbortSignal, + started?: (name: string) => () => void, ): Promise { const startedAt = Date.now(); const signal = def.interruptible && interrupt && ctx.signal ? AbortSignal.any([ctx.signal, interrupt]) : ctx.signal; + let finished: (() => void) | undefined; return withAnchors({ call: callId }, () => Promise.resolve() - .then(() => def.handler(args, { ...ctx, callId, signal })) + .then(() => { + finished = started?.(def.name); + return def.handler(args, { ...ctx, callId, signal }); + }) .then((out): ToolOutcome => (typeof out === 'string' ? { text: out } : out)) .catch((e: unknown): ToolOutcome => ({ text: toolFailed(e instanceof Error ? e.message : String(e)), failed: true, })) + .finally(() => finished?.()) .then((out): ToolOutcome => (def.interruptible && interrupt?.aborted ? { ...out, text: `${out.text} ${INTERRUPTED_WHILE_RUNNING}` } @@ -2001,6 +2087,7 @@ class EagerDispatch { private readonly interrupt?: AbortSignal, /** 轮到执行时返回未执行标记则跳过该调用。 */ private readonly skip: () => string | null = () => null, + private readonly started?: (name: string) => () => void, ) {} @@ -2039,7 +2126,7 @@ class EagerDispatch { const run = this.chain.then(() => { const skipped = this.active() ? this.skip() : NOT_EXECUTED_LOOP_STOPPED; return skipped === null - ? runToolHandler(def, args, this.ctx, call.id, this.role, this.toolLog, this.active, this.owner(def.name), this.interrupt) + ? runToolHandler(def, args, this.ctx, call.id, this.role, this.toolLog, this.active, this.owner(def.name), this.interrupt, this.started) : { text: skipped }; }); this.chain = run.then(() => undefined); diff --git a/src/core/types.ts b/src/core/types.ts index edac46ac..515d3bb3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -856,6 +856,24 @@ export interface ShutdownExternalCheck { manualAction: string; } +/** 主 session 循环此刻在做什么;每个字段都是 Core 直接观察到的事实。 */ +export interface RunPhase { + /** + * idle:等待下一批事件,批末钩子也在此状态运行;delivering:把一批事件写入主 session,含等待 + * Persona.onDelivery;model:模型调用进行中;tools:本轮模型输出已结束,执行其中的工具调用; + * backoff:模型调用失败,等待重试;handoff:上下文交接进行中。 + */ + state: 'idle' | 'delivering' | 'model' | 'tools' | 'backoff' | 'handoff'; + /** 本批的轮序号,从 1 起;轮间的 delivering 带当前轮。idle、handoff 与一批的首次 delivering 时缺省。 */ + round?: number; + /** 正在执行的工具名,按开始顺序;包含模型输出期间提前执行的调用。 */ + running: readonly string[]; + /** backoff 时下一次请求的时刻(ISO)。 */ + retryAt?: string; + /** 进入当前 state 的时刻(ISO,部署时区)。 */ + since: string; +} + /** World 的环境描述、事件和工具契约。 */ export interface World { id: string; @@ -896,6 +914,11 @@ export interface World { * 之后经 queueExternalEvents 写入 session 时通知。 */ onEventsSettled?(events: readonly EventEnvelope[], outcome: 'delivered' | 'discarded'): void; + /** + * 主循环的 RunPhase 变化时同步调用:state 或 round 改变、工具开始或结束。在主循环的调用栈上执行, + * 只记录状态,不做耗时操作。隐藏 World 不接收通知。 + */ + onRunPhase?(phase: RunPhase): void; /** * stop() 完成后返回外部状态检查的同步只读快照。 * 网络检查须在 stop() 的既有期限内完成并缓存。 diff --git a/tests/core/loop.test.ts b/tests/core/loop.test.ts index b80c8ab4..72e7cb79 100644 --- a/tests/core/loop.test.ts +++ b/tests/core/loop.test.ts @@ -19,7 +19,7 @@ import { SessionTracker } from '../../src/core/sessions.ts'; import { INTERRUPTED_WHILE_RUNNING, NOT_EXECUTED_INTERRUPTED } from '../../src/core/markers.ts'; import type { UsageRecord, Persona } from '../../src/core/types.ts'; import type { ChatMessage, LLMDelta } from './fixture-types.ts'; -import type { Logger, CandidateProjector, EventEnvelope, World, WorldHost, ToolDef } from '../../src/core/types.ts'; +import type { Logger, CandidateProjector, EventEnvelope, RunPhase, World, WorldHost, ToolDef } from '../../src/core/types.ts'; import type { BotConfig } from '../../bots/corti-soulmate/assemble.ts'; import { activeSpec, @@ -3538,6 +3538,64 @@ describe("MainLoop 重新请求、轮次边界与统计", () => { }); }); +describe('MainLoop RunPhase', () => { + let rig: ReturnType; + afterEach(async () => { + if (rig) await rig.cleanup(); + }); + + /** 启动并等开场那一批回到 idle,再清空已记录的通知。 */ + async function startQuiet(seen: RunPhase[]): Promise { + rig.start(); + await until(() => rig.llm.calls.length >= 1 && seen.at(-1)?.state === 'idle'); + seen.length = 0; + } + + it('带工具调用的一批:可见 World 依次看到投递、模型、工具开始与结束、下一轮模型、空闲;隐藏 World 收不到', async () => { + const seen: RunPhase[] = []; + let hiddenCalls = 0; + const pet: World = { ...makeFakeIO('pet', [makeTool('walk', async () => { await sleep(20); return 'ok'; })]), onRunPhase: (p) => { seen.push(p); } }; + const hidden: World = { ...makeFakeIO('hidden'), onRunPhase: () => { hiddenCalls++; } }; + rig = makeRig({ worlds: [pet, hidden], hiddenWorlds: ['hidden'] }); + await startQuiet(seen); + rig.llm.script(toolReply([{ name: 'walk' }])); + rig.pushEvent('去散步'); + await until(() => seen.at(-1)?.state === 'idle'); + expect(seen.map((p) => [p.state, p.round, p.running])).toEqual([ + ['delivering', undefined, []], + ['model', 1, []], + ['tools', 1, []], + ['tools', 1, ['walk']], + ['tools', 1, []], + ['model', 2, []], + ['idle', undefined, []], + ]); + expect(rig.loop.getStatus().phase).toBe(seen.at(-1)); + expect(hiddenCalls).toBe(0); + }); + + it('可重试的失败进入 backoff,retryAt 是重新请求的时刻', async () => { + const delayMs = 120; + const seen: RunPhase[] = []; + const pet: World = { ...makeFakeIO('pet'), onRunPhase: (p) => { seen.push(p); } }; + rig = makeRig({ worlds: [pet], resubmit: { maxConsecutive: 2, maxPerBatch: 4, backoffMs: [delayMs] } }); + await startQuiet(seen); + rig.llm.throwNext = new Error('upstream down'); + rig.llm.script(textReply('好了')); + rig.pushEvent('在吗'); + await until(() => seen.at(-1)?.state === 'idle'); + expect(seen.map((p) => [p.state, p.round])).toEqual([ + ['delivering', undefined], ['model', 1], ['backoff', 1], ['model', 2], ['idle', undefined], + ]); + const [, , backoff, retry] = seen; + const retryInMs = Date.parse(backoff.retryAt!) - Date.parse(backoff.since); + expect(retryInMs).toBeGreaterThan(0); + expect(retryInMs).toBeLessThanOrEqual(delayMs); + // 定时器按毫秒取整,允许早 1 毫秒。 + expect(Date.parse(retry.since)).toBeGreaterThanOrEqual(Date.parse(backoff.retryAt!) - 1); + }); +}); + describe('MainLoop 异步 onDelivery', () => { let rig: ReturnType; afterEach(async () => { From 971f1ccde16576b22bc9c56618c9b4f95e0dd579 Mon Sep 17 00:00:00 2001 From: Phant Date: Mon, 5 Oct 2026 19:00:19 -0500 Subject: [PATCH 2/4] =?UTF-8?q?feat(console):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E6=8C=89=20core=20=E7=9A=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E6=98=BE=E7=A4=BA=E7=8A=B6=E6=80=81=E8=A1=8C?= =?UTF-8?q?,=E5=8F=96=E4=BB=A3=E6=8C=89=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E6=8E=A8=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /ws/debug 新增 phase 帧,RunPhase 每次变化时推送。status 帧要估算 token、扫描投递积压, 不随工具开始与结束重算。 - 时间线下方那行原先在最后一条是 user Item 时显示「已进上下文,尚无 assistant 输出」; 现在显示投递、等待模型、执行工具(带工具名)、重试时刻或交接,idle 时收起。查看后台会话 或调试通道断开时不显示。 - 测试覆盖追加 user 消息不再点亮状态行,phase 帧决定显示与收起。 Co-Authored-By: Claude Opus 5.5 --- docs/console.md | 2 +- src/bot.ts | 1 + src/web/README.md | 2 ++ src/web/client/features/live/index.ts | 24 ++++++++++++++++++++++- src/web/client/features/live/protocol.ts | 10 ++++++++++ src/web/client/features/live/status.ts | 20 ++++++++++++++++++- src/web/client/features/live/strings.ts | 16 +++++++++++++-- src/web/client/features/live/timeline.ts | 25 +++++++++++------------- src/web/server.ts | 6 +++++- tests/web/feature-live.test.ts | 19 ++++++++++++++++++ 10 files changed, 105 insertions(+), 20 deletions(-) diff --git a/docs/console.md b/docs/console.md index 950c4d48..ea8597c5 100644 --- a/docs/console.md +++ b/docs/console.md @@ -30,7 +30,7 @@ SameSite=Strict、Max-Age 取浏览器上限 400 天;请求经 HTTPS 或反向 | 路由 | 页 | 内容 | |---|---|---| -| `live` | 终端 | 与 bot 对话、时间线、上下文圈、fork;全新部署上多一组开场引导 | +| `live` | 终端 | 与 bot 对话、时间线、主循环运行阶段、上下文圈、fork;全新部署上多一组开场引导 | | `core` | 运行诊断 | run、session、事件、运行日志,以及 Core 自己的数据与配置 | | `usage` | 用量与成本 | 按 session、按天的 token 与费用 | | `providers` | 模型供应商 | 端点表(见 [providers.md](providers.md)) | diff --git a/src/bot.ts b/src/bot.ts index ec8be58b..475aced2 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1243,6 +1243,7 @@ export function createBot( onSessionReset: (cb) => core.session.onReset(cb), onEvent: (cb) => core.store.onAppend(cb), onRunlog: (cb) => core.runlog.onWrite(cb), + onRunPhase: (cb) => core.loop.onRunPhase(cb), runId: () => core.run.id, toolSchemas: () => core.loop.getToolSchemas(), }, diff --git a/src/web/README.md b/src/web/README.md index 439040b0..d8ea225d 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -51,6 +51,8 @@ manifest 的 `CONSOLE_PROTOCOL_VERSION` 不匹配时,浏览器拒绝加载。 `WebApp` 默认监听 `127.0.0.1`,支持由依赖配置指定监听地址。从首选端口起最多尝试五个端口; 端口为 0 时仅申请一次系统分配。WebSocket 使用 `noServer` 分派 `/ws/debug`、`/ws/sessions` 和面板流。 +`/ws/debug` 在主循环的 `RunPhase` 改变时推 `phase` 帧,值与 `status` 帧的 `loop.phase` 相同; +`status` 帧只在 session 追加、暂停与继续、端点变更时推送。 所有请求与 upgrade 先校验 Host 头:只接受回环名、显式绑定的地址和 `allowedHosts` 里的名字,绑到通配地址时不校验。 写请求与 upgrade 再按 Host 校验 Origin,拒绝不匹配或无效的 Origin;缺少 Origin 时放行,Origin 是 `allowedHosts` 里的名字时放行。监听非回环地址而没有设访问密码时启动记一条 warn。 diff --git a/src/web/client/features/live/index.ts b/src/web/client/features/live/index.ts index 4b2fe6b6..2faeadae 100644 --- a/src/web/client/features/live/index.ts +++ b/src/web/client/features/live/index.ts @@ -18,7 +18,9 @@ import { exportDiagnostics } from './diagnostics.ts'; import { createForkView, MAIN_ID, MAIN_LABEL } from './fork.ts'; import { arr, + loopOf, str, + type RunPhase, type SessionStat, type StatusSnapshot, type ToolSchemaDoc, @@ -26,7 +28,7 @@ import { import { icon } from '../../ui/icons.ts'; import { createOnboarding, type OnboardingView } from './onboarding.ts'; import { createSessionBand } from './sessions.ts'; -import { applyDisplayName, createStatusBand } from './status.ts'; +import { applyDisplayName, createStatusBand, phaseLabel } from './status.ts'; import { S } from './strings.ts'; import { createTimeline } from './timeline.ts'; @@ -82,6 +84,8 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void { status: null as StatusSnapshot | null, sessions: [] as SessionStat[], displayName: '', + /** 主循环运行阶段,只取自调试通道的帧;断线时清空。 */ + phase: null as RunPhase | null, }; const view = ui.h('div', 'liveview'); @@ -143,8 +147,17 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void { if (event.key === 'Escape') closeCtx(); }, { signal: ctx.signal }); + /** 运行阶段说的是主 session,查看后台会话时不显示。 */ + const syncActivity = (): void => { + timeline.setActivity(fork.isMain() ? phaseLabel(ui, state.phase) : null); + }; + let netEl: HTMLElement = ui.pill(S.netConnecting, 'plain'); const setNet = (online: boolean): void => { + if (!online) { + state.phase = null; + syncActivity(); + } const next = ui.pill(online ? S.netOnline : S.netOffline, online ? 'on' : 'off'); netEl.replaceWith(next); netEl = next; @@ -259,6 +272,7 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void { onChange: () => { sessionBand.render(state.sessions, fork.id); syncPlaceholder(); + syncActivity(); }, onError: (err) => ctx.onError(err), }); @@ -316,6 +330,8 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void { setStatus((f.status as StatusSnapshot | null) ?? null); timeline.rebuild(state.messages, { head: state.head }); setSessions(arr(f.sessions)); + state.phase = loopOf(state.status).phase ?? null; + syncActivity(); break; } case 'session.append': { @@ -344,6 +360,12 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void { break; case 'status': setStatus((f.status as StatusSnapshot | null) ?? null); + state.phase = loopOf(state.status).phase ?? null; + syncActivity(); + break; + case 'phase': + state.phase = (f.phase as RunPhase | null) ?? null; + syncActivity(); break; case 'sessions': setSessions(arr(f.sessions)); diff --git a/src/web/client/features/live/protocol.ts b/src/web/client/features/live/protocol.ts index cde575ed..b7c50739 100644 --- a/src/web/client/features/live/protocol.ts +++ b/src/web/client/features/live/protocol.ts @@ -52,6 +52,15 @@ export interface ContextBudget { countedTokens?: number; } +/** core 主循环的运行阶段:`status.loop.phase` 与 `phase` 帧;字段含义见 core 的 RunPhase。 */ +export interface RunPhase { + state: 'idle' | 'delivering' | 'model' | 'tools' | 'backoff' | 'handoff'; + round?: number; + running: readonly string[]; + retryAt?: string; + since: string; +} + export interface LoopStatus { estTokens?: number | null; messageCount?: number | null; @@ -66,6 +75,7 @@ export interface LoopStatus { /** 水位之后该进上下文却还没投递的外部事件数。 */ behind?: number | null; context?: ContextBudget | null; + phase?: RunPhase | null; } /** Persona自报的一枚状态筹码。框架照文本渲染,不解释里面说的是什么。 */ diff --git a/src/web/client/features/live/status.ts b/src/web/client/features/live/status.ts index 78c773d9..86698b34 100644 --- a/src/web/client/features/live/status.ts +++ b/src/web/client/features/live/status.ts @@ -3,7 +3,7 @@ */ import type { ConsoleUi } from '../../../shared/client-panel.ts'; -import { chipsOf, loopOf, type StatusSnapshot } from './protocol.ts'; +import { chipsOf, loopOf, type RunPhase, type StatusSnapshot } from './protocol.ts'; import { S } from './strings.ts'; /** 一枚 chip:纯文本片段与要加粗的读数交替。 */ @@ -28,6 +28,24 @@ export function applyDisplayName(doc: Document, name: string | undefined, curren return true; } +/** 主循环运行阶段的一行说明;idle 或没有阶段时为 null。 */ +export function phaseLabel(ui: ConsoleUi, phase: RunPhase | null): string | null { + if (!phase) return null; + let head: string; + switch (phase.state) { + case 'delivering': head = S.phaseDelivering; break; + case 'model': head = S.phaseModel; break; + case 'tools': head = S.phaseTools; break; + case 'backoff': head = S.phaseBackoff(ui.fmt.clock(phase.retryAt)); break; + case 'handoff': head = S.phaseHandoff; break; + default: return null; + } + const parts = [head]; + if (phase.round !== undefined) parts.push(S.phaseRound(phase.round)); + if (phase.running.length > 0) parts.push(S.phaseRunning(phase.running.join(', '))); + return parts.join(' · '); +} + export interface StatusBand { el: HTMLElement; render(st: StatusSnapshot | null): void; diff --git a/src/web/client/features/live/strings.ts b/src/web/client/features/live/strings.ts index b6d6a51f..cd49ce57 100644 --- a/src/web/client/features/live/strings.ts +++ b/src/web/client/features/live/strings.ts @@ -39,6 +39,13 @@ const zh = { chipScheduleBlocked: '◷ 闹钟阻断', chipTruncating: '截断中', chipOnline: '在线 ', + phaseDelivering: '正在把事件写入上下文', + phaseModel: '等待模型输出', + phaseTools: '执行工具', + phaseBackoff: (clock: string) => `模型调用失败,${clock} 重试`, + phaseHandoff: '上下文交接中', + phaseRound: (n: number) => `第 ${n} 轮`, + phaseRunning: (names: string) => `运行中:${names}`, // fork.ts mainLabel: "主 session", @@ -67,7 +74,6 @@ const zh = { `输入 ${prompt} / 输出 ${completion} / 命中 ${hit}`, // timeline.ts - thinking: '已进上下文,尚无 assistant 输出', jumpBottom: '↓ 回到底部', foldHead: (open: boolean, n: number) => `${open ? '▾' : '▸'} ${n} 字`, ordinalTitle: (index: number) => `session 第 ${index} 条 Item`, @@ -171,6 +177,13 @@ const en: typeof zh = { chipScheduleBlocked: '◷ Schedule blocked', chipTruncating: 'Truncating', chipOnline: 'online ', + phaseDelivering: 'Writing events into context', + phaseModel: 'Waiting for model output', + phaseTools: 'Running tools', + phaseBackoff: (clock: string) => `Model call failed; retrying at ${clock}`, + phaseHandoff: 'Context handoff in progress', + phaseRound: (n: number) => `round ${n}`, + phaseRunning: (names: string) => `running: ${names}`, // fork.ts mainLabel: "Main session", @@ -199,7 +212,6 @@ const en: typeof zh = { `input ${prompt} / output ${completion} / cache hits ${hit}`, // timeline.ts - thinking: 'In context; no assistant output yet', jumpBottom: '↓ Back to bottom', foldHead: (open: boolean, n: number) => `${open ? '▾' : '▸'} ${n} chars`, ordinalTitle: (index: number) => `Item #${index} in session`, diff --git a/src/web/client/features/live/timeline.ts b/src/web/client/features/live/timeline.ts index 3d6a8d55..32d6967f 100644 --- a/src/web/client/features/live/timeline.ts +++ b/src/web/client/features/live/timeline.ts @@ -98,6 +98,8 @@ export interface TimelineView { ): void; /** 追加一条(实时帧)。`live` 决定要不要动画与打字机。 */ append(m: ContextRecord, index: number, live: boolean): void; + /** 时间线下方的一行运行状态;null 时隐藏。重画不动它。 */ + setActivity(text: string | null): void; /** 说话人展示名。头像图片加载不到时,ASSISTANT 组左栏的占位圆里印它的首字;下一次画到组时生效。 */ setSpeaker(name: string): void; /** 开场引导期间把系统前缀那张卡收起来,下一次重画生效。 */ @@ -113,8 +115,9 @@ export function createTimeline(deps: TimelineDeps): TimelineView { const scroll = ui.h('div', 'tlscroll'); const inner = ui.h('div', 'tlinner'); scroll.appendChild(inner); - const think = ui.h('div', 'tlthink hidden'); - think.append(ui.h('span', 'pulse'), ui.h('span', null, S.thinking)); + const activity = ui.h('div', 'tlthink hidden'); + const activityText = ui.h('span'); + activity.append(ui.h('span', 'pulse'), activityText); const jump = ui.button(S.jumpBottom, { size: 'sm', onClick: () => { @@ -123,7 +126,7 @@ export function createTimeline(deps: TimelineDeps): TimelineView { }, }); jump.className = 'btn sm tljump hidden'; - el.append(scroll, think, jump); + el.append(scroll, activity, jump); /** call_id → 那颗卡上等结果的槽位。每次重画清空。 */ const toolCalls = new Map(); @@ -495,7 +498,6 @@ export function createTimeline(deps: TimelineDeps): TimelineView { const renderOne = (entry: ContextRecord, index: Ordinal, live: boolean): void => { const item = entry.item; if (isAssistantSide(item)) { - if (live) hideThinking(); const t = ensureTurn(entry, index, live); t.entries.push(entry); const status = entry.context.responseStatus; @@ -514,7 +516,6 @@ export function createTimeline(deps: TimelineDeps): TimelineView { let node: HTMLElement | null; if (item.type === 'message' && item.role === 'user') { node = renderWorld(entry, index, live); - if (live) showThinking(); } else if (isPrefix(item)) node = hideSystem ? null : renderSystem(entry, live); else if (item.type === 'function_call_output') node = renderToolResult(entry, index, live); else node = renderOther(entry, index, live); @@ -530,21 +531,12 @@ export function createTimeline(deps: TimelineDeps): TimelineView { inner.appendChild(ui.h('div', 'divider sessionhead', S.headEnd)); }; - function showThinking(): void { - toggleClass(think, 'hidden', false); - stick(); - } - function hideThinking(): void { - toggleClass(think, 'hidden', true); - } - return { el, rebuild(messages, opts) { const wasStuck = autoScroll; const keepTop = scroll.scrollTop; stopTyping(); - hideThinking(); toolCalls.clear(); turn = null; inner.replaceChildren(); @@ -576,6 +568,11 @@ export function createTimeline(deps: TimelineDeps): TimelineView { append(entry, index, live) { renderOne(entry, index, live); }, + setActivity(text) { + activityText.textContent = text ?? ''; + toggleClass(activity, 'hidden', text === null); + if (text !== null) stick(); + }, setHideSystem(hide) { hideSystem = hide; }, diff --git a/src/web/server.ts b/src/web/server.ts index 5978a5c7..a80c366a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -21,7 +21,7 @@ import { WebSocketServer, type WebSocket } from 'ws'; import type { EventEnvelope, EventRangeQuery, EventStoreReader, Logger, ConfigGroup, ConfigValues, WorldConsoleDecl, - LogRecord, OwnedStoragePart, StoragePart, ToolSchema, + LogRecord, OwnedStoragePart, StoragePart, ToolSchema, RunPhase, } from '../core/types.ts'; import type { SessionStats } from '../core/sessions.ts'; import type { UsageAggregate, UsageBucketOption } from '../core/cost.ts'; @@ -101,6 +101,8 @@ export interface WebAppDebugDeps { onSessionReset(cb: (messages: ContextRecord[]) => void): void; onEvent(cb: (e: EventEnvelope) => void): void; onRunlog(cb: (entry: LogRecord) => void): void; + /** 主循环的 RunPhase 变化;缺席时状态帧里的 loop.phase 只随其他帧更新。 */ + onRunPhase?(cb: (phase: RunPhase) => void): void; /** 当前 run id;/api/log 缺省读它的 log.jsonl */ runId?(): string; /** 主循环当前工具表schema(run()前为空数组) */ @@ -881,6 +883,8 @@ export class WebApp { })); dbg.onEvent((envelope) => this.debugBroadcast({ t: 'event', envelope })); dbg.onRunlog((entry) => this.debugBroadcast({ t: 'runlog', entry })); + // 工具每次开始与结束都会变,单独成帧:status 帧要估算 token、扫描投递积压,不随它重算。 + dbg.onRunPhase?.((phase) => this.debugBroadcast({ t: 'phase', phase })); } // session统计变化→全量列表推送(列表小,每次LLM调用一帧,频率低)。 // 同帧也走debug通道(chat调试台已连/ws/debug,免开第二条连接)。 diff --git a/tests/web/feature-live.test.ts b/tests/web/feature-live.test.ts index d44dc03a..6fe365f6 100644 --- a/tests/web/feature-live.test.ts +++ b/tests/web/feature-live.test.ts @@ -622,6 +622,25 @@ describe('live feature 挂载', () => { expect(doc.title).toBe('控制台 · 某某'); }); + it('时间线下方那行只按 core 的运行阶段显示:追加 user 消息不显示,phase 帧给出轮次与工具名,idle 收起', () => { + const { env, sockets } = fakeEnv(); + const { ctx, root } = mkCtx({ debug: true, sessions: true }); + live.createLiveFeature({ env }).mount(ctx); + sockets[0].up(); + const idle = { state: 'idle', running: [], since: '2026-10-05T10:00:00+08:00' }; + sockets[0].emit({ t: 'hello', session: [{ role: 'system', content: '前缀' }], status: { loop: { phase: idle } } }); + const line = root.find('tlthink') as FakeEl; + sockets[0].emit({ t: 'session.append', index: 1, message: { role: 'user', content: '在吗' } }); + expect(line.className).toContain('hidden'); + + sockets[0].emit({ t: 'phase', phase: { state: 'tools', round: 2, running: ['walk'], since: '2026-10-05T10:00:01+08:00' } }); + expect(line.className).not.toContain('hidden'); + expect(line.textContent).toBe('执行工具 · 第 2 轮 · 运行中:walk'); + + sockets[0].emit({ t: 'phase', phase: idle }); + expect(line.className).toContain('hidden'); + }); + it('工具回执按 call_id 填回等结果的那个槽位,不新开一张卡', () => { const { env, sockets } = fakeEnv(); const { ctx, root } = mkCtx({ debug: true, sessions: true }); From 0d1aa4b9ce879d377a6d13128ebbc8a5a3a485bf Mon Sep 17 00:00:00 2001 From: Phant Date: Mon, 5 Oct 2026 22:33:37 -0500 Subject: [PATCH 3/4] =?UTF-8?q?refactor(core):=20RunPhase.since=20?= =?UTF-8?q?=E6=94=B9=E5=90=8D=20enteredAt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISO 时刻按 AGENTS.md §2 以 *At 命名,与同一接口的 retryAt 一致。 Co-Authored-By: Claude Opus 5.5 --- src/core/loop.ts | 6 +++--- src/core/types.ts | 2 +- tests/core/loop.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/loop.ts b/src/core/loop.ts index b6dc456c..a5f4997b 100644 --- a/src/core/loop.ts +++ b/src/core/loop.ts @@ -340,7 +340,7 @@ export class MainLoop { constructor(deps: MainLoopDeps) { this.d = deps; - this.phase = { state: 'idle', running: [], since: nowIso(deps.cfg.timezone) }; + this.phase = { state: 'idle', running: [], enteredAt: nowIso(deps.cfg.timezone) }; deps.session.onReset(() => { this.anchor = null; }); } @@ -349,7 +349,7 @@ export class MainLoop { this.phaseListeners.push(listener); } - /** state、round 或 retryAt 不变时不通知,since 保持进入时的时刻。 */ + /** state、round 或 retryAt 不变时不通知,enteredAt 保持进入时的时刻。 */ private enterPhase(state: RunPhase['state'], detail: { round?: number; retryAt?: string } = {}): void { const cur = this.phase; if (cur.state === state && cur.round === detail.round && cur.retryAt === detail.retryAt) return; @@ -358,7 +358,7 @@ export class MainLoop { ...(detail.round !== undefined ? { round: detail.round } : {}), running: [...this.runningTools.values()], ...(detail.retryAt !== undefined ? { retryAt: detail.retryAt } : {}), - since: nowIso(this.d.cfg.timezone), + enteredAt: nowIso(this.d.cfg.timezone), }; this.emitPhase(); } diff --git a/src/core/types.ts b/src/core/types.ts index 515d3bb3..5e2f4504 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -871,7 +871,7 @@ export interface RunPhase { /** backoff 时下一次请求的时刻(ISO)。 */ retryAt?: string; /** 进入当前 state 的时刻(ISO,部署时区)。 */ - since: string; + enteredAt: string; } /** World 的环境描述、事件和工具契约。 */ diff --git a/tests/core/loop.test.ts b/tests/core/loop.test.ts index 72e7cb79..97a33b89 100644 --- a/tests/core/loop.test.ts +++ b/tests/core/loop.test.ts @@ -3588,11 +3588,11 @@ describe('MainLoop RunPhase', () => { ['delivering', undefined], ['model', 1], ['backoff', 1], ['model', 2], ['idle', undefined], ]); const [, , backoff, retry] = seen; - const retryInMs = Date.parse(backoff.retryAt!) - Date.parse(backoff.since); + const retryInMs = Date.parse(backoff.retryAt!) - Date.parse(backoff.enteredAt); expect(retryInMs).toBeGreaterThan(0); expect(retryInMs).toBeLessThanOrEqual(delayMs); // 定时器按毫秒取整,允许早 1 毫秒。 - expect(Date.parse(retry.since)).toBeGreaterThanOrEqual(Date.parse(backoff.retryAt!) - 1); + expect(Date.parse(retry.enteredAt)).toBeGreaterThanOrEqual(Date.parse(backoff.retryAt!) - 1); }); }); From cbbc6985aea3b2488e48947f1830c803b2ed1334 Mon Sep 17 00:00:00 2001 From: Phant Date: Mon, 5 Oct 2026 22:33:37 -0500 Subject: [PATCH 4/4] =?UTF-8?q?refactor(console):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E7=9A=84=20RunPhase=20=E5=89=AF=E6=9C=AC=E9=9A=8F=20c?= =?UTF-8?q?ore=20=E6=94=B9=E4=B8=BA=20enteredAt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/web/client/features/live/protocol.ts | 2 +- tests/web/feature-live.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/client/features/live/protocol.ts b/src/web/client/features/live/protocol.ts index b7c50739..2f879ce7 100644 --- a/src/web/client/features/live/protocol.ts +++ b/src/web/client/features/live/protocol.ts @@ -58,7 +58,7 @@ export interface RunPhase { round?: number; running: readonly string[]; retryAt?: string; - since: string; + enteredAt: string; } export interface LoopStatus { diff --git a/tests/web/feature-live.test.ts b/tests/web/feature-live.test.ts index 6fe365f6..72402282 100644 --- a/tests/web/feature-live.test.ts +++ b/tests/web/feature-live.test.ts @@ -627,13 +627,13 @@ describe('live feature 挂载', () => { const { ctx, root } = mkCtx({ debug: true, sessions: true }); live.createLiveFeature({ env }).mount(ctx); sockets[0].up(); - const idle = { state: 'idle', running: [], since: '2026-10-05T10:00:00+08:00' }; + const idle = { state: 'idle', running: [], enteredAt: '2026-10-05T10:00:00+08:00' }; sockets[0].emit({ t: 'hello', session: [{ role: 'system', content: '前缀' }], status: { loop: { phase: idle } } }); const line = root.find('tlthink') as FakeEl; sockets[0].emit({ t: 'session.append', index: 1, message: { role: 'user', content: '在吗' } }); expect(line.className).toContain('hidden'); - sockets[0].emit({ t: 'phase', phase: { state: 'tools', round: 2, running: ['walk'], since: '2026-10-05T10:00:01+08:00' } }); + sockets[0].emit({ t: 'phase', phase: { state: 'tools', round: 2, running: ['walk'], enteredAt: '2026-10-05T10:00:01+08:00' } }); expect(line.className).not.toContain('hidden'); expect(line.textContent).toBe('执行工具 · 第 2 轮 · 运行中:walk');