@changfenhuang/dsh-genui 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,10 @@
8
8
  *
9
9
  * The loop is deliberately narrow, matching the contract agreed on the issue:
10
10
  * - **默认开启。** 插件配置中的 `fenceFeedback: false` 可以关闭回合转向。
11
- * - **Bounded.** At most one correction per turn AND at most one per fence
12
- * body per process, so a correction that is itself wrong cannot loop.
11
+ * - **修正上限。** 每个 turn 最多发送两条 correction;render failure 和 delivery reminder 共用上限,
12
+ * 每个 fence body 在当前回合中最多修正一次,新回合可重新修正。
13
+ * - **仅含 reasoning 的恢复。** 已验证的 GenUI 回合若只以 reasoning block 结束,使用宿主的
14
+ * `EMPTY_RESPONSE` retry policy。
13
15
  * - **Never for subagents.** A child session's fence belongs to a parent reply.
14
16
  * - **Exact fence matching.** Only an info string of exactly `dsh-ui` opens a
15
17
  * fence, so ` ```dsh-ui-dark `, indented prose, or a mention of the name is
@@ -25,8 +27,8 @@
25
27
 
26
28
  import type { Context } from '@deepseek-ai/cordis'
27
29
  import type {} from '@deepseek-ai/dsh-agent'
28
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
29
- import type { UserMessage } from '@deepseek-ai/dsh-session'
30
+ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
31
+ import type { Session, SessionEvent, SessionStore, UserMessage } from '@deepseek-ai/dsh-session'
30
32
  import { createHash, randomUUID } from 'node:crypto'
31
33
  import { droppedNodeFailure } from './genui-diagnostic.ts'
32
34
  import { resolveFence } from '../shared/fence-resolve.ts'
@@ -38,6 +40,12 @@ export const FEEDBACK_SOURCE_KIND = `plugin:${FEEDBACK_PLUGIN_NAME}` as const
38
40
 
39
41
  /** Marker prefix inside the correction text: `[genui-fence-repair #<fingerprint>]`. */
40
42
  const MARKER_PREFIX = '[genui-fence-repair #'
43
+ /**
44
+ * 同一个 turn 内 fence-feedback correction 的共享上限。
45
+ * Render failure 和 nothing-delivered reminder 共用该 budget;各自的 ledger 独立阻止重复修正。
46
+ */
47
+ export const MAX_CORRECTIONS_PER_TURN = 2
48
+
41
49
  /** Marker prefix written by older plugin versions. */
42
50
  const LEGACY_MARKER_PREFIX = '[genui 自修 #'
43
51
 
@@ -177,21 +185,64 @@ export function createFeedbackMessage(text: string, sessionFormatVersion: number
177
185
 
178
186
  /** Per-session bookkeeping for the loop (process lifetime). */
179
187
  interface SessionFeedback {
180
- /** Latest assistant reply text of the current turn. */
188
+ /** Latest assistant reply text of the current turn (fences are read from here). */
181
189
  text: string
182
- /** Fence fingerprints already corrected in this process. */
183
- corrected: Set<string>
184
- /** Turn that already received its one correction. */
185
- lastCorrectedTurn: number | undefined
190
+ /**
191
+ * A `validate_dsh_ui` call happened in this turn. This is the FORMAL signal
192
+ * that the turn is GenUI-related — the loop never guesses intent by scanning
193
+ * the reasoning block.
194
+ */
195
+ validatedThisTurn: boolean
196
+ /** 子代理会话的输出不会直接交给用户。 */
197
+ isSubagent: boolean
198
+ /**
199
+ * The turn delivered something formal: a non-empty body text, or a
200
+ * `render_ui` call whose RESULT reports success. A failed render is not an
201
+ * answer, and a call whose result has not arrived yet proves nothing.
202
+ */
203
+ deliveredThisTurn: boolean
204
+ /**
205
+ * `render_ui` calls of this turn whose result has not arrived yet. The
206
+ * result — not the call — decides delivery, so an outstanding call blocks
207
+ * steering at the boundary instead of being guessed about.
208
+ */
209
+ pendingRenders: Set<string>
210
+ /** Fence fingerprints already corrected for a RENDER failure in the current turn. */
211
+ correctedSpec: Set<string>
212
+ /**
213
+ * Turns already given the "nothing was delivered" reminder. Kept separate from
214
+ * {@link correctedSpec}: a missing delivery must not consume the render-failure
215
+ * ledger (nor the other way round).
216
+ */
217
+ deliveryRemindedTurns: Set<number>
218
+ /** Corrections already steered in {@link correctionsTurn}. */
219
+ correctionsThisTurn: number
220
+ /** Turn {@link correctionsThisTurn} counts. */
221
+ correctionsTurn: number | undefined
222
+ /** 从 session event 中恢复的当前 turn。 */
223
+ currentTurn: number | undefined
224
+ /** 已计入 correction budget 的 message ID。 */
225
+ accountedCorrectionMessageIds: Set<string>
186
226
  }
187
227
 
188
228
  /** What the pure planner needs to decide whether a correction may be sent. */
189
229
  export interface FenceFeedbackPlanInput {
230
+ /** Latest assistant reply text of the current turn. */
190
231
  readonly text: string
191
232
  readonly turn: number
192
- readonly lastCorrectedTurn: number | undefined
193
- readonly corrected: ReadonlySet<string>
233
+ /** Fence bodies already corrected for a render failure in the current turn. */
234
+ readonly correctedSpec: ReadonlySet<string>
235
+ /** Turns already given the delivery reminder. */
236
+ readonly deliveryRemindedTurns?: ReadonlySet<number> | undefined
237
+ /** A `validate_dsh_ui` call happened this turn (formal GenUI signal). */
238
+ readonly validatedThisTurn?: boolean | undefined
239
+ /** The turn already delivered a body or a successful `render_ui` result. */
240
+ readonly deliveredThisTurn?: boolean | undefined
194
241
  readonly aborted: boolean
242
+ /** Corrections already steered in this turn (shared hard cap). */
243
+ readonly correctionsThisTurn?: number | undefined
244
+ /** Turn {@link correctionsThisTurn} counts (stale counts are ignored). */
245
+ readonly correctionsTurn?: number | undefined
195
246
  }
196
247
 
197
248
  /** A correction the caller must account for before steering. */
@@ -199,26 +250,100 @@ export interface FenceFeedbackPlan {
199
250
  readonly text: string
200
251
  readonly fingerprints: readonly string[]
201
252
  readonly turn: number
253
+ /**
254
+ * `render` corrects a fence that failed to resolve; `delivery` reminds the
255
+ * model that the turn produced nothing formal. They share the per-turn budget
256
+ * but keep separate ledgers.
257
+ */
258
+ readonly kind: 'render' | 'delivery'
202
259
  }
203
260
 
204
261
  /**
205
- * Decide whether this turn boundary should steer a fence correction — the pure
206
- * core of the loop, so every bound (one per turn, one per fence, cancellation)
207
- * is testable without a host.
262
+ * Decide whether this turn boundary should steer a correction — the pure core of
263
+ * the loop, so every bound (per-turn cap, per-fence ledger, cancellation) is
264
+ * testable without a host.
208
265
  *
209
- * @param input - reply text, turn identity, and the session's accounting.
266
+ * The decision is driven by FORMAL events only: fences in the reply body, a
267
+ * `validate_dsh_ui` call, a delivered body text or a successful `render_ui`
268
+ * result. The reasoning block is
269
+ * never read here — a draft inside the thinking block is not proof that the model
270
+ * chose to deliver it, so it must not change any decision.
271
+ *
272
+ * @param input - reply text, turn identity, formal signals, and the accounting.
210
273
  * @returns the correction to send, or null when the loop must stay silent.
211
274
  */
212
275
  export function planFenceFeedback(input: FenceFeedbackPlanInput): FenceFeedbackPlan | null {
213
276
  if (input.aborted) return null
214
- if (input.lastCorrectedTurn === input.turn) return null
215
- if (input.text.trim() === '') return null
216
- const failures = fenceFailures(input.text).filter(failure => !input.corrected.has(failure.fingerprint))
217
- if (failures.length === 0) return null
218
- return {
219
- text: fenceCorrectionText(failures),
220
- fingerprints: failures.map(failure => failure.fingerprint),
221
- turn: input.turn,
277
+ const used = input.correctionsTurn === input.turn ? (input.correctionsThisTurn ?? 0) : 0
278
+ if (used >= MAX_CORRECTIONS_PER_TURN) return null
279
+ const failures = fenceFailures(input.text).filter(failure => !input.correctedSpec.has(failure.fingerprint))
280
+ if (failures.length > 0) {
281
+ return {
282
+ text: fenceCorrectionText(failures),
283
+ fingerprints: failures.map(failure => failure.fingerprint),
284
+ turn: input.turn,
285
+ kind: 'render',
286
+ }
287
+ }
288
+ // No un-corrected render failure. If the turn formally validated a spec and
289
+ // then delivered nothing, remind the model once for this turn.
290
+ if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null
291
+ if (input.deliveryRemindedTurns?.has(input.turn) === true) return null
292
+ return { text: missingBodyCorrectionText(input.turn, used + 1), fingerprints: [], turn: input.turn, kind: 'delivery' }
293
+ }
294
+
295
+ /**
296
+ * Correction for a validated GenUI turn that reached the boundary without any
297
+ * formal delivery.
298
+ *
299
+ * @param turn - turn that reached the boundary.
300
+ * @param attempt - correction number within the shared turn budget.
301
+ * @returns the message text to steer into the running turn.
302
+ */
303
+ export function missingBodyCorrectionText(turn: number, attempt = 1): string {
304
+ const head = `${MARKER_PREFIX}turn-${turn}]\n\n[genui-fence-repair]\nstatus=nothing_delivered\nfences=0\nnext=emit_fence_in_body\nrepeat_rendered_content=false\nreply_language=conversation\n\n`
305
+ const emphasis = attempt <= 1 ? '' : `(第 ${attempt} 次提醒)`
306
+ return `${head}本轮尚未产生正式回答,也没有通过支持的通道交付结果${emphasis}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏,或明确调用 render_ui。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`
307
+ }
308
+
309
+ /**
310
+ * 将 GenUI 回合中仅含 reasoning 的完整响应转换为宿主已有的可重试空响应错误。
311
+ *
312
+ * @param options - LLM stream waterfall 拦截的请求。
313
+ * @param source - 本次请求的 provider stream。
314
+ * @returns 原始 stream;仅符合条件的终止 stop 会被改写。
315
+ */
316
+ async function* retryReasoningOnlyGenuiStream(
317
+ options: GenerateOptions,
318
+ source: AsyncIterable<StreamChunk>,
319
+ ): AsyncGenerator<StreamChunk> {
320
+ const { EMPTY_RESPONSE_CODE, isAgentLoopRequest } = await import('@deepseek-ai/dsh-llm')
321
+ if (!isAgentLoopRequest(options)) {
322
+ yield* source
323
+ return
324
+ }
325
+
326
+ let hasReasoningBlock = false
327
+ let hasOtherBlock = false
328
+ for await (const chunk of source) {
329
+ if (chunk.type === 'block-end') {
330
+ if (chunk.block.type === 'reasoning') hasReasoningBlock = true
331
+ else hasOtherBlock = true
332
+ }
333
+ if (chunk.type === 'finish' && chunk.reason.kind === 'stop' && hasReasoningBlock && !hasOtherBlock) {
334
+ yield {
335
+ type: 'finish',
336
+ reason: {
337
+ kind: 'error',
338
+ failure: {
339
+ message: 'GenUI turn completed with reasoning only and no deliverable response',
340
+ code: EMPTY_RESPONSE_CODE,
341
+ },
342
+ },
343
+ }
344
+ } else {
345
+ yield chunk
346
+ }
222
347
  }
223
348
  }
224
349
 
@@ -235,9 +360,77 @@ function textOfContent(content: unknown): string {
235
360
  .join('\n')
236
361
  }
237
362
 
238
- /** Fingerprints this loop already recorded inside a steered correction. */
239
- function markersIn(text: string): string[] {
240
- const out: string[] = []
363
+ type RenderResultStatus = 'rendered' | 'invalid'
364
+
365
+ /** 读取 render_ui result protocol 中明确返回的 status。 */
366
+ function renderResultStatus(content: unknown): RenderResultStatus | undefined {
367
+ const lines = textOfContent(content).split(/\r?\n/u)
368
+ if (lines[0]?.trim() !== '[genui-render]') return undefined
369
+ for (const line of lines.slice(1)) {
370
+ if (line === 'status=rendered') return 'rendered'
371
+ if (line === 'status=invalid') return 'invalid'
372
+ }
373
+ return undefined
374
+ }
375
+
376
+ interface ObservedToolResult {
377
+ readonly callId: string
378
+ readonly isError: boolean
379
+ readonly content: unknown
380
+ }
381
+
382
+ /** 从 legacy wrapper 与 Session format v4 中提取 tool result 数据。 */
383
+ function observedToolResult(message: unknown): ObservedToolResult | null {
384
+ if (typeof message !== 'object' || message === null) return null
385
+ const record = message as { toolCallId?: unknown; isError?: unknown; content?: unknown }
386
+ if (typeof record.toolCallId === 'string') {
387
+ return { callId: record.toolCallId, isError: record.isError === true, content: record.content }
388
+ }
389
+ if (!Array.isArray(record.content)) return null
390
+ const block = record.content.find(part => typeof part === 'object' && part !== null
391
+ && (part as { type?: unknown }).type === 'tool-result') as {
392
+ toolCallId?: unknown
393
+ isError?: unknown
394
+ content?: unknown
395
+ } | undefined
396
+ if (typeof block?.toolCallId !== 'string') return null
397
+ return { callId: block.toolCallId, isError: block.isError === true, content: block.content }
398
+ }
399
+
400
+ /** Tool whose SUCCESSFUL result is a formal delivery. */
401
+ const DELIVERY_TOOL = 'render_ui'
402
+
403
+ /**
404
+ * Whether one assistant message delivered a non-empty body text.
405
+ *
406
+ * Deliberately narrow: a non-empty text block. A `render_ui` call is decided
407
+ * by its `tool/result`, not by the call appearing in a message — a call that
408
+ * failed (or whose result has not arrived) is not a delivery. Other tools
409
+ * (validate_dsh_ui, bash, …) may succeed without producing any answer, so
410
+ * they are NOT counted as delivery.
411
+ *
412
+ * @param content - assistant content blocks.
413
+ * @returns true when this message delivered a body text.
414
+ */
415
+ function deliveredBodyText(content: unknown): boolean {
416
+ if (!Array.isArray(content)) return false
417
+ return content.some(block => {
418
+ if (typeof block !== 'object' || block === null) return false
419
+ const record = block as { type?: unknown; text?: unknown }
420
+ return record.type === 'text' && typeof record.text === 'string' && record.text.trim() !== ''
421
+ })
422
+ }
423
+
424
+
425
+ interface FeedbackMarkers {
426
+ readonly renderFingerprints: string[]
427
+ readonly deliveryTurns: number[]
428
+ }
429
+
430
+ /** 分类当前与 legacy correction message 中的 marker。 */
431
+ function feedbackMarkersIn(text: string): FeedbackMarkers {
432
+ const renderFingerprints: string[] = []
433
+ const deliveryTurns: number[] = []
241
434
  let cursor = 0
242
435
  while (cursor < text.length) {
243
436
  const current = text.indexOf(MARKER_PREFIX, cursor)
@@ -248,10 +441,23 @@ function markersIn(text: string): string[] {
248
441
  const index = useLegacy ? legacy : current
249
442
  const end = text.indexOf(']', index + prefix.length)
250
443
  if (end < 0) break
251
- out.push(text.slice(index + prefix.length, end))
444
+ const marker = text.slice(index + prefix.length, end)
445
+ const turn = /^turn-(\d+)$/u.exec(marker)
446
+ if (turn !== null) deliveryTurns.push(Number(turn[1]))
447
+ else renderFingerprints.push(marker)
252
448
  cursor = end + 1
253
449
  }
254
- return out
450
+ return { renderFingerprints, deliveryTurns }
451
+ }
452
+
453
+ /** 恢复一条持久化 correction 消耗的 turn budget。 */
454
+ function accountReplayedCorrection(state: SessionFeedback, turn: number): void {
455
+ if (state.correctionsTurn !== turn) {
456
+ state.correctionsTurn = turn
457
+ state.correctionsThisTurn = 1
458
+ return
459
+ }
460
+ state.correctionsThisTurn = Math.min(MAX_CORRECTIONS_PER_TURN, state.correctionsThisTurn + 1)
255
461
  }
256
462
 
257
463
  /** Identify this plugin's source across current and migrated session shapes. */
@@ -261,18 +467,29 @@ function isFeedbackSource(source: { kind?: unknown; plugin?: unknown } | undefin
261
467
  }
262
468
 
263
469
  /**
264
- * 根据插件配置启用围栏反馈流程。
470
+ * 注册 GenUI 回合跟踪、宿主重试判定和可选的围栏修正流程。
265
471
  *
266
- * @param ctx - the host context.
267
- * @param enabled - the plugin config flag; the loop is inert when false.
472
+ * @param ctx - 宿主 Context。
473
+ * @param enabled - 是否启用同回合围栏修正。
268
474
  */
269
475
  export function installFenceFeedback(ctx: Context, enabled: boolean): void {
270
- if (!enabled) return
271
476
  const sessions = new Map<string, SessionFeedback>()
272
477
  const stateOf = (sessionId: string): SessionFeedback => {
273
478
  let state = sessions.get(sessionId)
274
479
  if (state === undefined) {
275
- state = { text: '', corrected: new Set(), lastCorrectedTurn: undefined }
480
+ state = {
481
+ text: '',
482
+ validatedThisTurn: false,
483
+ isSubagent: false,
484
+ deliveredThisTurn: false,
485
+ pendingRenders: new Set(),
486
+ correctedSpec: new Set(),
487
+ deliveryRemindedTurns: new Set(),
488
+ correctionsThisTurn: 0,
489
+ correctionsTurn: undefined,
490
+ currentTurn: undefined,
491
+ accountedCorrectionMessageIds: new Set(),
492
+ }
276
493
  sessions.set(sessionId, state)
277
494
  }
278
495
  return state
@@ -282,53 +499,155 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void {
282
499
  sessions.delete(String(session.id))
283
500
  })
284
501
 
285
- ctx.on('session/event', (session, event: SessionEvent) => {
502
+ const resetTurnState = (state: SessionFeedback, turn: number | undefined): void => {
503
+ state.text = ''
504
+ state.validatedThisTurn = false
505
+ state.deliveredThisTurn = false
506
+ state.pendingRenders.clear()
507
+ state.currentTurn = turn
508
+ }
509
+
510
+ const observeEvent = (session: Session, event: SessionEvent): void => {
286
511
  const sessionId = String(session.id)
512
+ if (event.type === 'turn/start') {
513
+ // The formal turn boundary: whatever happened in the previous turn is
514
+ // settled and this turn starts clean. The `user/message` reset below is
515
+ // only a fallback for direct prompts.
516
+ const state = stateOf(sessionId)
517
+ state.isSubagent = session.header.parentSession !== undefined
518
+ // 去重只约束当前回合,新一轮仍可修正相同错误。
519
+ state.correctedSpec.clear()
520
+ resetTurnState(state, (event.data as { turn?: number }).turn)
521
+ return
522
+ }
287
523
  if (event.type === 'assistant/message') {
288
- const text = textOfContent((event.data as { message?: { content?: unknown } }).message?.content)
289
- if (extractDshUiFences(text).length === 0) {
290
- const state = sessions.get(sessionId)
291
- if (state !== undefined) state.text = ''
292
- return
524
+ const content = (event.data as { message?: { content?: unknown } }).message?.content
525
+ const text = textOfContent(content)
526
+ const state = stateOf(sessionId)
527
+ state.isSubagent = session.header.parentSession !== undefined
528
+ // Fences are read from the BODY only: a draft in the reasoning block is not
529
+ // a delivery and must not become one.
530
+ state.text = extractDshUiFences(text).length > 0 ? text : ''
531
+ // Delivery is sticky for the turn: once a body text exists, the turn has
532
+ // answered and must never be corrected into publishing again. A
533
+ // `render_ui` call in the content proves nothing here — its result does.
534
+ state.deliveredThisTurn = state.deliveredThisTurn || deliveredBodyText(content)
535
+ return
536
+ }
537
+ if (event.type === 'tool/call') {
538
+ const data = event.data as { name?: unknown; callId?: unknown }
539
+ const state = stateOf(sessionId)
540
+ state.isSubagent = session.header.parentSession !== undefined
541
+ // validate_dsh_ui is the FORMAL signal that this turn is GenUI-related.
542
+ if (data.name === 'validate_dsh_ui') state.validatedThisTurn = true
543
+ // The result — not the call — decides whether render_ui delivered.
544
+ if (data.name === DELIVERY_TOOL && typeof data.callId === 'string') {
545
+ state.pendingRenders.add(data.callId)
546
+ }
547
+ return
548
+ }
549
+ if (event.type === 'tool/result') {
550
+ const data = event.data as { message?: unknown; error?: unknown }
551
+ const result = observedToolResult(data.message)
552
+ if (result === null) return
553
+ const state = stateOf(sessionId)
554
+ state.isSubagent = session.header.parentSession !== undefined
555
+ if (!state.pendingRenders.delete(result.callId)) return
556
+ if (data.error === undefined && result.isError !== true && renderResultStatus(result.content) === 'rendered') {
557
+ state.deliveredThisTurn = true
293
558
  }
294
- stateOf(sessionId).text = text
295
559
  return
296
560
  }
297
561
  if (event.type !== 'user/message') return
298
- const data = event.data as { content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
562
+ const data = event.data as { id?: unknown; content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
299
563
  if (isFeedbackSource(data.source)) {
300
- // Our own correction (re-observed after a plugin reload): adopt its
301
- // fingerprints so a second boundary cannot repeat it.
302
- const fingerprints = markersIn(textOfContent(data.content))
303
- if (fingerprints.length === 0) return
564
+ const markers = feedbackMarkersIn(textOfContent(data.content))
565
+ if (markers.renderFingerprints.length === 0 && markers.deliveryTurns.length === 0) return
304
566
  const state = stateOf(sessionId)
305
- for (const fingerprint of fingerprints) state.corrected.add(fingerprint)
567
+ state.isSubagent = session.header.parentSession !== undefined
568
+ for (const fingerprint of markers.renderFingerprints) state.correctedSpec.add(fingerprint)
569
+ for (const turn of markers.deliveryTurns) state.deliveryRemindedTurns.add(turn)
570
+ if (typeof data.id === 'string' && !state.accountedCorrectionMessageIds.has(data.id)) {
571
+ state.accountedCorrectionMessageIds.add(data.id)
572
+ const turn = markers.deliveryTurns[0] ?? state.currentTurn
573
+ if (turn !== undefined) accountReplayedCorrection(state, turn)
574
+ }
306
575
  return
307
576
  }
308
- // A genuine user prompt starts a new turn: the previous reply is settled.
577
+ // A DIRECT human prompt starts a new turn: the previous reply is settled.
578
+ // Synthetic context that rides the same user surface — `agent.inject()`
579
+ // notices, team member messages — carries a different source kind and
580
+ // arrives MID-TURN: resetting here used to wipe the turn's validation and
581
+ // delivery state before the boundary could use it.
582
+ if (data.source?.kind !== 'user') return
309
583
  const state = sessions.get(sessionId)
310
- if (state !== undefined) state.text = ''
584
+ if (state !== undefined) {
585
+ state.isSubagent = session.header.parentSession !== undefined
586
+ // turn/start 先于用户消息;清掉正文状态时保留正式回合身份。
587
+ resetTurnState(state, state.currentTurn)
588
+ }
589
+ }
590
+
591
+ const restoreHistory = (session: Session): void => {
592
+ if (sessions.has(String(session.id))) return
593
+ for (const event of session.snapshotEvents()) observeEvent(session, event)
594
+ }
595
+ ctx.on('session/event', (session, event) => {
596
+ // 服务注入是异步的;首条实时事件也先接管历史,避免丢失旧纠错次数。
597
+ restoreHistory(session)
598
+ observeEvent(session, event)
311
599
  })
600
+ ctx.on('session/created', restoreHistory)
601
+ const restoreSessions = (sessionCtx: Context): void => {
602
+ const store = sessionCtx.reflect.get('sessions') as SessionStore | undefined
603
+ if (store === undefined) return
604
+ for (const session of store.list()) restoreHistory(session)
605
+ }
606
+ // 宿主不重播历史;安装时同步接管已有会话,异步注入负责晚到的服务。
607
+ restoreSessions(ctx)
608
+ ctx.inject(['sessions'], restoreSessions)
609
+
610
+ ctx.on('llm/stream', (options, next) => {
611
+ if (options.sessionId === undefined || options.purpose !== undefined) return next()
612
+ const state = sessions.get(String(options.sessionId))
613
+ if (state?.validatedThisTurn !== true || state.isSubagent) return next()
614
+ return retryReasoningOnlyGenuiStream(options, next())
615
+ }, { global: true })
312
616
 
313
617
  ctx.on('agent/turn-stopping', ({ agent, turn, signal }): void => {
618
+ if (!enabled) return
314
619
  // A child session's fence belongs to a parent reply, and an aborted turn is
315
620
  // on its way out: never steer into either.
316
621
  if (agent.session.header.parentSession !== undefined) return
317
622
  const state = sessions.get(String(agent.session.id))
318
623
  if (state === undefined) return
624
+ state.currentTurn = turn
625
+ // A render_ui result still outstanding could deliver (or fail) after this
626
+ // boundary fires: steering now would race the late result, so stay silent
627
+ // and let the next boundary decide on settled facts.
628
+ if (state.pendingRenders.size > 0) return
629
+ const usedThisTurn = state.correctionsTurn === turn ? state.correctionsThisTurn : 0
319
630
  const plan = planFenceFeedback({
320
631
  text: state.text,
321
632
  turn,
322
- lastCorrectedTurn: state.lastCorrectedTurn,
323
- corrected: state.corrected,
633
+ correctedSpec: state.correctedSpec,
634
+ deliveryRemindedTurns: state.deliveryRemindedTurns,
635
+ validatedThisTurn: state.validatedThisTurn,
636
+ deliveredThisTurn: state.deliveredThisTurn,
324
637
  aborted: signal.aborted,
638
+ correctionsThisTurn: usedThisTurn,
639
+ correctionsTurn: state.correctionsTurn,
325
640
  })
326
641
  if (plan === null) return
327
642
  // Account BEFORE sending: a re-entrant boundary must not deliver twice.
328
- for (const fingerprint of plan.fingerprints) state.corrected.add(fingerprint)
329
- state.lastCorrectedTurn = plan.turn
643
+ for (const fingerprint of plan.fingerprints) state.correctedSpec.add(fingerprint)
644
+ if (plan.kind === 'delivery') state.deliveryRemindedTurns.add(plan.turn)
645
+ state.correctionsTurn = plan.turn
646
+ state.correctionsThisTurn = usedThisTurn + 1
330
647
  try {
331
- agent.steer(createFeedbackMessage(plan.text, agent.session.header.version))
648
+ const message = createFeedbackMessage(plan.text, agent.session.header.version)
649
+ state.accountedCorrectionMessageIds.add(message.id)
650
+ agent.steer(message)
332
651
  } catch (error) {
333
652
  ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`)
334
653
  }
@@ -119,12 +119,13 @@ Rules:
119
119
  - JSON 严格:坏组件被丢弃,坏围栏变代码块;≥3 节点或含 table 时调 validate_dsh_ui,按诊断修改并重验;小围栏字段存疑也先验证。
120
120
  - warning=block_markdown:按 replacement 改写并重验。
121
121
  - 规模: ≤200 节点、嵌套≤8 层(超出被截断);一条回答 3–8 个组件,一个主题一个主组件;3D mesh 1–5;plot 给合理 xMin/xMax。
122
- - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互组件带 "action":"name",交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
123
- - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;重渲染相同内容保留,新内容重置。
122
+ - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
123
+ - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
124
124
  - 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
125
125
  - Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
126
- - Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片(交付物型界面用);围栏用于回答内联 UI。
127
- - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并(同标签 tabs 追加/新标签加入/尾部追加);上限 200 节点/200 次追加,满了发 replace 重建。面板组件来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认,不解释、不用普通围栏。`
126
+ - Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。
127
+ - 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
128
+ - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`
128
129
 
129
130
  /**
130
131
  * Register the GenUI output-language section and the render_ui tool.
@@ -184,8 +185,8 @@ function bundledSkillProvider(): SkillProvider {
184
185
  export interface GenuiPluginConfig {
185
186
  /**
186
187
  * 在最终 dsh-ui 围栏无法渲染的回合中请求模型发送一次修正版(issue #160)。
187
- * 默认开启,设置为 false 可以关闭。每回合和每个围栏正文最多请求一次,子代理不触发,
188
- * 每次请求会消耗模型步数。
188
+ * 默认开启,设置为 false 可以关闭同回合围栏修正。GenUI 回合的 reasoning-only 响应仍会交给宿主重试策略处理。
189
+ * 每回合和每个围栏正文最多请求一次,子代理不触发,每次修正请求会消耗模型步数。
189
190
  */
190
191
  fenceFeedback?: boolean
191
192
  }