@changfenhuang/dsh-genui 0.11.3 → 0.12.0
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.
- package/CHANGELOG.md +44 -0
- package/README.md +21 -6
- package/README.zh-CN.md +21 -6
- package/SKILL.md +18 -6
- package/lib/assets/mermaid.js +325 -325
- package/lib/assets/standalone-runtime.js +10 -9
- package/lib/client.js +31 -33
- package/lib/index.js +1060 -371
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/dom-fence.d.ts +2 -1
- package/lib/types/client/file-address.d.ts +4 -0
- package/lib/types/client/file-link-context.d.ts +41 -0
- package/lib/types/client/file-link.d.ts +17 -0
- package/lib/types/client/i18n/en.d.ts +5 -1
- package/lib/types/client/inline.d.ts +1 -1
- package/lib/types/client/mermaid-colors.d.ts +2 -0
- package/lib/types/client/panel.d.ts +6 -1
- package/lib/types/client/spec.d.ts +8 -11
- package/lib/types/client/submission-registry.d.ts +51 -0
- package/lib/types/client/table-details.d.ts +20 -0
- package/lib/types/client/walk-spec.d.ts +3 -0
- package/lib/types/plugin/fence-feedback.d.ts +55 -12
- package/lib/types/plugin/index.d.ts +7 -3
- package/lib/types/shared/fence-repair.d.ts +19 -7
- package/package.json +1 -12
- package/src/client/GenuiBlock.module.css +100 -4
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/artifact/ArtifactExport.module.css +34 -3
- package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
- package/src/client/blocks/charts.tsx +63 -14
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +11 -8
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +190 -50
- package/src/client/file-address.ts +37 -0
- package/src/client/file-link-context.tsx +114 -0
- package/src/client/file-link.ts +46 -0
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +98 -153
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.tsx +23 -5
- package/src/client/inline.ts +28 -10
- package/src/client/mermaid-colors.ts +39 -0
- package/src/client/mermaid-core.ts +7 -3
- package/src/client/panel.tsx +18 -5
- package/src/client/spec.ts +8 -11
- package/src/client/submission-registry.ts +150 -0
- package/src/client/table-details.ts +151 -0
- package/src/client/walk-spec.ts +43 -0
- package/src/plugin/fence-feedback.ts +384 -53
- package/src/plugin/index.ts +20 -9
- package/src/plugin/tool.ts +8 -2
- package/src/shared/fence-repair.ts +345 -128
- package/tsdown.config.ts +3 -7
- package/lib/invariant.js +0 -19
- package/lib/types/plugin/invariant.d.ts +0 -15
- package/src/plugin/invariant.ts +0 -30
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GenuiNode, GenuiSpec } from './spec.ts'
|
|
2
|
+
import { GENUI_LIMITS } from './genui-runtime/index.ts'
|
|
3
|
+
import { isTableDetailReachable, tableRowsForDetails } from './table-details.ts'
|
|
4
|
+
|
|
5
|
+
/** 遍历完整 GenUI 组件树,并提供每个组件在 spec 中的路径。 */
|
|
6
|
+
export function walkGenuiNodes(spec: GenuiSpec, visitor: (node: GenuiNode, path: string) => void): void {
|
|
7
|
+
const walk = (nodes: GenuiNode[], path: string, depth: number): void => {
|
|
8
|
+
if (depth > GENUI_LIMITS.maxDepth) return
|
|
9
|
+
nodes.forEach((node, index) => visit(node, `${path}[${index}]`, depth))
|
|
10
|
+
}
|
|
11
|
+
const visit = (node: GenuiNode, at: string, depth: number): void => {
|
|
12
|
+
if (depth > GENUI_LIMITS.maxDepth) return
|
|
13
|
+
visitor(node, at)
|
|
14
|
+
if (depth >= GENUI_LIMITS.maxDepth) return
|
|
15
|
+
switch (node.type) {
|
|
16
|
+
case 'row':
|
|
17
|
+
case 'col':
|
|
18
|
+
case 'grid':
|
|
19
|
+
case 'card':
|
|
20
|
+
walk(node.items, `${at}.items`, depth + 1)
|
|
21
|
+
break
|
|
22
|
+
case 'tabs':
|
|
23
|
+
node.tabs.forEach((tab, tabIndex) => walk(tab.items, `${at}.tabs[${tabIndex}].items`, depth + 1))
|
|
24
|
+
break
|
|
25
|
+
case 'accordion':
|
|
26
|
+
node.items.forEach((item, itemIndex) => walk(item.items, `${at}.items[${itemIndex}].items`, depth + 1))
|
|
27
|
+
break
|
|
28
|
+
case 'list':
|
|
29
|
+
node.items.forEach((item, itemIndex) => {
|
|
30
|
+
if (item !== null && typeof item === 'object' && 'type' in item) visit(item, `${at}.items[${itemIndex}]`, depth + 1)
|
|
31
|
+
})
|
|
32
|
+
break
|
|
33
|
+
case 'table':
|
|
34
|
+
node.details?.slice(0, tableRowsForDetails(node).length).forEach((detail, rowIndex) => {
|
|
35
|
+
if (detail !== null && isTableDetailReachable(node, rowIndex)) {
|
|
36
|
+
walk(detail, `${at}.details[${rowIndex}]`, depth + 1)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(spec.items, 'items', 0)
|
|
43
|
+
}
|
|
@@ -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
|
-
* -
|
|
12
|
-
*
|
|
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 {
|
|
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
|
|
|
@@ -115,9 +123,10 @@ export function fenceFailures(text: string): FenceFailure[] {
|
|
|
115
123
|
|
|
116
124
|
/** `null` when this fence renders; otherwise the reason it does not. */
|
|
117
125
|
function fenceFailureDetail(fence: ExtractedFence): string | null {
|
|
118
|
-
if (!fence.closed) return 'error=unterminated_fence\nrequired=closing_fence'
|
|
119
126
|
const resolution = resolveFence(fence.raw, { settled: true })
|
|
120
127
|
if (resolution.spec !== null) return null
|
|
128
|
+
// 缺少 Markdown 收尾不等于渲染失败;先复用 renderer 判断,避免把已显示的卡片重发(#288)。
|
|
129
|
+
if (!fence.closed) return 'error=unterminated_fence\nrequired=closing_fence'
|
|
121
130
|
if (resolution.processed !== null) {
|
|
122
131
|
const dropped = droppedNodeFailure(resolution.processed, resolution.value)
|
|
123
132
|
return dropped?.join('\n')
|
|
@@ -177,21 +186,66 @@ export function createFeedbackMessage(text: string, sessionFormatVersion: number
|
|
|
177
186
|
|
|
178
187
|
/** Per-session bookkeeping for the loop (process lifetime). */
|
|
179
188
|
interface SessionFeedback {
|
|
180
|
-
/** Latest assistant reply text of the current turn. */
|
|
189
|
+
/** Latest assistant reply text of the current turn (fences are read from here). */
|
|
181
190
|
text: string
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
/**
|
|
192
|
+
* A `validate_dsh_ui` call happened in this turn. This is the FORMAL signal
|
|
193
|
+
* that the turn is GenUI-related — the loop never guesses intent by scanning
|
|
194
|
+
* the reasoning block.
|
|
195
|
+
*/
|
|
196
|
+
validatedThisTurn: boolean
|
|
197
|
+
/** 子代理会话的输出不会直接交给用户。 */
|
|
198
|
+
isSubagent: boolean
|
|
199
|
+
/**
|
|
200
|
+
* The turn delivered something formal: a non-empty body text, or a
|
|
201
|
+
* `render_ui` call whose RESULT reports success. A failed render is not an
|
|
202
|
+
* answer, and a call whose result has not arrived yet proves nothing.
|
|
203
|
+
*/
|
|
204
|
+
deliveredThisTurn: boolean
|
|
205
|
+
/**
|
|
206
|
+
* `render_ui` calls of this turn whose result has not arrived yet. The
|
|
207
|
+
* result — not the call — decides delivery, so an outstanding call blocks
|
|
208
|
+
* steering at the boundary instead of being guessed about.
|
|
209
|
+
*/
|
|
210
|
+
pendingRenders: Set<string>
|
|
211
|
+
/** Fence fingerprints already corrected for a RENDER failure in the current turn. */
|
|
212
|
+
correctedSpec: Set<string>
|
|
213
|
+
/**
|
|
214
|
+
* Turns already given the "nothing was delivered" reminder. Kept separate from
|
|
215
|
+
* {@link correctedSpec}: a missing delivery must not consume the render-failure
|
|
216
|
+
* ledger (nor the other way round).
|
|
217
|
+
*/
|
|
218
|
+
deliveryRemindedTurns: Set<number>
|
|
219
|
+
/** Corrections already steered in {@link correctionsTurn}. */
|
|
220
|
+
correctionsThisTurn: number
|
|
221
|
+
/** Turn {@link correctionsThisTurn} counts. */
|
|
222
|
+
correctionsTurn: number | undefined
|
|
223
|
+
/** 从 session event 中恢复的当前 turn。 */
|
|
224
|
+
currentTurn: number | undefined
|
|
225
|
+
/** 已计入 correction budget 的 message ID。 */
|
|
226
|
+
accountedCorrectionMessageIds: Set<string>
|
|
186
227
|
}
|
|
187
228
|
|
|
188
229
|
/** What the pure planner needs to decide whether a correction may be sent. */
|
|
189
230
|
export interface FenceFeedbackPlanInput {
|
|
231
|
+
/** Latest assistant reply text of the current turn. */
|
|
190
232
|
readonly text: string
|
|
191
233
|
readonly turn: number
|
|
192
|
-
|
|
193
|
-
readonly
|
|
234
|
+
/** Fence bodies already corrected for a render failure in the current turn. */
|
|
235
|
+
readonly correctedSpec: ReadonlySet<string>
|
|
236
|
+
/** Turns already given the delivery reminder. */
|
|
237
|
+
readonly deliveryRemindedTurns?: ReadonlySet<number> | undefined
|
|
238
|
+
/** A `validate_dsh_ui` call happened this turn (formal GenUI signal). */
|
|
239
|
+
readonly validatedThisTurn?: boolean | undefined
|
|
240
|
+
/** The turn already delivered a body or a successful `render_ui` result. */
|
|
241
|
+
readonly deliveredThisTurn?: boolean | undefined
|
|
194
242
|
readonly aborted: boolean
|
|
243
|
+
/** Corrections already steered in this turn (shared hard cap). */
|
|
244
|
+
readonly correctionsThisTurn?: number | undefined
|
|
245
|
+
/** Turn {@link correctionsThisTurn} counts (stale counts are ignored). */
|
|
246
|
+
readonly correctionsTurn?: number | undefined
|
|
247
|
+
/** Whether the host has registered the `render_ui` tool. Defaults to enabled. */
|
|
248
|
+
readonly renderUiToolEnabled?: boolean | undefined
|
|
195
249
|
}
|
|
196
250
|
|
|
197
251
|
/** A correction the caller must account for before steering. */
|
|
@@ -199,26 +253,107 @@ export interface FenceFeedbackPlan {
|
|
|
199
253
|
readonly text: string
|
|
200
254
|
readonly fingerprints: readonly string[]
|
|
201
255
|
readonly turn: number
|
|
256
|
+
/**
|
|
257
|
+
* `render` corrects a fence that failed to resolve; `delivery` reminds the
|
|
258
|
+
* model that the turn produced nothing formal. They share the per-turn budget
|
|
259
|
+
* but keep separate ledgers.
|
|
260
|
+
*/
|
|
261
|
+
readonly kind: 'render' | 'delivery'
|
|
202
262
|
}
|
|
203
263
|
|
|
204
264
|
/**
|
|
205
|
-
* Decide whether this turn boundary should steer a
|
|
206
|
-
*
|
|
207
|
-
*
|
|
265
|
+
* Decide whether this turn boundary should steer a correction — the pure core of
|
|
266
|
+
* the loop, so every bound (per-turn cap, per-fence ledger, cancellation) is
|
|
267
|
+
* testable without a host.
|
|
208
268
|
*
|
|
209
|
-
*
|
|
269
|
+
* The decision is driven by FORMAL events only: fences in the reply body, a
|
|
270
|
+
* `validate_dsh_ui` call, a delivered body text or a successful `render_ui`
|
|
271
|
+
* result. The reasoning block is
|
|
272
|
+
* never read here — a draft inside the thinking block is not proof that the model
|
|
273
|
+
* chose to deliver it, so it must not change any decision.
|
|
274
|
+
*
|
|
275
|
+
* @param input - reply text, turn identity, formal signals, and the accounting.
|
|
210
276
|
* @returns the correction to send, or null when the loop must stay silent.
|
|
211
277
|
*/
|
|
212
278
|
export function planFenceFeedback(input: FenceFeedbackPlanInput): FenceFeedbackPlan | null {
|
|
213
279
|
if (input.aborted) return null
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
const failures = fenceFailures(input.text).filter(failure => !input.
|
|
217
|
-
if (failures.length
|
|
280
|
+
const used = input.correctionsTurn === input.turn ? (input.correctionsThisTurn ?? 0) : 0
|
|
281
|
+
if (used >= MAX_CORRECTIONS_PER_TURN) return null
|
|
282
|
+
const failures = fenceFailures(input.text).filter(failure => !input.correctedSpec.has(failure.fingerprint))
|
|
283
|
+
if (failures.length > 0) {
|
|
284
|
+
return {
|
|
285
|
+
text: fenceCorrectionText(failures),
|
|
286
|
+
fingerprints: failures.map(failure => failure.fingerprint),
|
|
287
|
+
turn: input.turn,
|
|
288
|
+
kind: 'render',
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// No un-corrected render failure. If the turn formally validated a spec and
|
|
292
|
+
// then delivered nothing, remind the model once for this turn.
|
|
293
|
+
if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null
|
|
294
|
+
if (input.deliveryRemindedTurns?.has(input.turn) === true) return null
|
|
218
295
|
return {
|
|
219
|
-
text:
|
|
220
|
-
fingerprints:
|
|
296
|
+
text: missingBodyCorrectionText(input.turn, used + 1, input.renderUiToolEnabled ?? true),
|
|
297
|
+
fingerprints: [],
|
|
221
298
|
turn: input.turn,
|
|
299
|
+
kind: 'delivery',
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Correction for a validated GenUI turn that reached the boundary without any
|
|
305
|
+
* formal delivery.
|
|
306
|
+
*
|
|
307
|
+
* @param turn - turn that reached the boundary.
|
|
308
|
+
* @param attempt - correction number within the shared turn budget.
|
|
309
|
+
* @param renderUiToolEnabled - whether the correction may recommend `render_ui`.
|
|
310
|
+
* @returns the message text to steer into the running turn.
|
|
311
|
+
*/
|
|
312
|
+
export function missingBodyCorrectionText(turn: number, attempt = 1, renderUiToolEnabled = true): string {
|
|
313
|
+
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`
|
|
314
|
+
const emphasis = attempt <= 1 ? '' : `(第 ${attempt} 次提醒)`
|
|
315
|
+
const renderUiInstruction = renderUiToolEnabled ? ',或明确调用 render_ui' : ''
|
|
316
|
+
return `${head}本轮尚未产生正式回答,也没有通过支持的通道交付结果${emphasis}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏${renderUiInstruction}。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 将 GenUI 回合中仅含 reasoning 的完整响应转换为宿主已有的可重试空响应错误。
|
|
321
|
+
*
|
|
322
|
+
* @param options - LLM stream waterfall 拦截的请求。
|
|
323
|
+
* @param source - 本次请求的 provider stream。
|
|
324
|
+
* @returns 原始 stream;仅符合条件的终止 stop 会被改写。
|
|
325
|
+
*/
|
|
326
|
+
async function* retryReasoningOnlyGenuiStream(
|
|
327
|
+
options: GenerateOptions,
|
|
328
|
+
source: AsyncIterable<StreamChunk>,
|
|
329
|
+
): AsyncGenerator<StreamChunk> {
|
|
330
|
+
const { EMPTY_RESPONSE_CODE, isAgentLoopRequest } = await import('@deepseek-ai/dsh-llm')
|
|
331
|
+
if (!isAgentLoopRequest(options)) {
|
|
332
|
+
yield* source
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let hasReasoningBlock = false
|
|
337
|
+
let hasOtherBlock = false
|
|
338
|
+
for await (const chunk of source) {
|
|
339
|
+
if (chunk.type === 'block-end') {
|
|
340
|
+
if (chunk.block.type === 'reasoning') hasReasoningBlock = true
|
|
341
|
+
else hasOtherBlock = true
|
|
342
|
+
}
|
|
343
|
+
if (chunk.type === 'finish' && chunk.reason.kind === 'stop' && hasReasoningBlock && !hasOtherBlock) {
|
|
344
|
+
yield {
|
|
345
|
+
type: 'finish',
|
|
346
|
+
reason: {
|
|
347
|
+
kind: 'error',
|
|
348
|
+
failure: {
|
|
349
|
+
message: 'GenUI turn completed with reasoning only and no deliverable response',
|
|
350
|
+
code: EMPTY_RESPONSE_CODE,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
yield chunk
|
|
356
|
+
}
|
|
222
357
|
}
|
|
223
358
|
}
|
|
224
359
|
|
|
@@ -235,9 +370,77 @@ function textOfContent(content: unknown): string {
|
|
|
235
370
|
.join('\n')
|
|
236
371
|
}
|
|
237
372
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
373
|
+
type RenderResultStatus = 'rendered' | 'invalid'
|
|
374
|
+
|
|
375
|
+
/** 读取 render_ui result protocol 中明确返回的 status。 */
|
|
376
|
+
function renderResultStatus(content: unknown): RenderResultStatus | undefined {
|
|
377
|
+
const lines = textOfContent(content).split(/\r?\n/u)
|
|
378
|
+
if (lines[0]?.trim() !== '[genui-render]') return undefined
|
|
379
|
+
for (const line of lines.slice(1)) {
|
|
380
|
+
if (line === 'status=rendered') return 'rendered'
|
|
381
|
+
if (line === 'status=invalid') return 'invalid'
|
|
382
|
+
}
|
|
383
|
+
return undefined
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
interface ObservedToolResult {
|
|
387
|
+
readonly callId: string
|
|
388
|
+
readonly isError: boolean
|
|
389
|
+
readonly content: unknown
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 从 legacy wrapper 与 Session format v4 中提取 tool result 数据。 */
|
|
393
|
+
function observedToolResult(message: unknown): ObservedToolResult | null {
|
|
394
|
+
if (typeof message !== 'object' || message === null) return null
|
|
395
|
+
const record = message as { toolCallId?: unknown; isError?: unknown; content?: unknown }
|
|
396
|
+
if (typeof record.toolCallId === 'string') {
|
|
397
|
+
return { callId: record.toolCallId, isError: record.isError === true, content: record.content }
|
|
398
|
+
}
|
|
399
|
+
if (!Array.isArray(record.content)) return null
|
|
400
|
+
const block = record.content.find(part => typeof part === 'object' && part !== null
|
|
401
|
+
&& (part as { type?: unknown }).type === 'tool-result') as {
|
|
402
|
+
toolCallId?: unknown
|
|
403
|
+
isError?: unknown
|
|
404
|
+
content?: unknown
|
|
405
|
+
} | undefined
|
|
406
|
+
if (typeof block?.toolCallId !== 'string') return null
|
|
407
|
+
return { callId: block.toolCallId, isError: block.isError === true, content: block.content }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Tool whose SUCCESSFUL result is a formal delivery. */
|
|
411
|
+
const DELIVERY_TOOL = 'render_ui'
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Whether one assistant message delivered a non-empty body text.
|
|
415
|
+
*
|
|
416
|
+
* Deliberately narrow: a non-empty text block. A `render_ui` call is decided
|
|
417
|
+
* by its `tool/result`, not by the call appearing in a message — a call that
|
|
418
|
+
* failed (or whose result has not arrived) is not a delivery. Other tools
|
|
419
|
+
* (validate_dsh_ui, bash, …) may succeed without producing any answer, so
|
|
420
|
+
* they are NOT counted as delivery.
|
|
421
|
+
*
|
|
422
|
+
* @param content - assistant content blocks.
|
|
423
|
+
* @returns true when this message delivered a body text.
|
|
424
|
+
*/
|
|
425
|
+
function deliveredBodyText(content: unknown): boolean {
|
|
426
|
+
if (!Array.isArray(content)) return false
|
|
427
|
+
return content.some(block => {
|
|
428
|
+
if (typeof block !== 'object' || block === null) return false
|
|
429
|
+
const record = block as { type?: unknown; text?: unknown }
|
|
430
|
+
return record.type === 'text' && typeof record.text === 'string' && record.text.trim() !== ''
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
interface FeedbackMarkers {
|
|
436
|
+
readonly renderFingerprints: string[]
|
|
437
|
+
readonly deliveryTurns: number[]
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 分类当前与 legacy correction message 中的 marker。 */
|
|
441
|
+
function feedbackMarkersIn(text: string): FeedbackMarkers {
|
|
442
|
+
const renderFingerprints: string[] = []
|
|
443
|
+
const deliveryTurns: number[] = []
|
|
241
444
|
let cursor = 0
|
|
242
445
|
while (cursor < text.length) {
|
|
243
446
|
const current = text.indexOf(MARKER_PREFIX, cursor)
|
|
@@ -248,10 +451,23 @@ function markersIn(text: string): string[] {
|
|
|
248
451
|
const index = useLegacy ? legacy : current
|
|
249
452
|
const end = text.indexOf(']', index + prefix.length)
|
|
250
453
|
if (end < 0) break
|
|
251
|
-
|
|
454
|
+
const marker = text.slice(index + prefix.length, end)
|
|
455
|
+
const turn = /^turn-(\d+)$/u.exec(marker)
|
|
456
|
+
if (turn !== null) deliveryTurns.push(Number(turn[1]))
|
|
457
|
+
else renderFingerprints.push(marker)
|
|
252
458
|
cursor = end + 1
|
|
253
459
|
}
|
|
254
|
-
return
|
|
460
|
+
return { renderFingerprints, deliveryTurns }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 恢复一条持久化 correction 消耗的 turn budget。 */
|
|
464
|
+
function accountReplayedCorrection(state: SessionFeedback, turn: number): void {
|
|
465
|
+
if (state.correctionsTurn !== turn) {
|
|
466
|
+
state.correctionsTurn = turn
|
|
467
|
+
state.correctionsThisTurn = 1
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
state.correctionsThisTurn = Math.min(MAX_CORRECTIONS_PER_TURN, state.correctionsThisTurn + 1)
|
|
255
471
|
}
|
|
256
472
|
|
|
257
473
|
/** Identify this plugin's source across current and migrated session shapes. */
|
|
@@ -261,18 +477,30 @@ function isFeedbackSource(source: { kind?: unknown; plugin?: unknown } | undefin
|
|
|
261
477
|
}
|
|
262
478
|
|
|
263
479
|
/**
|
|
264
|
-
*
|
|
480
|
+
* 注册 GenUI 回合跟踪、宿主重试判定和可选的围栏修正流程。
|
|
265
481
|
*
|
|
266
|
-
* @param ctx -
|
|
267
|
-
* @param enabled -
|
|
482
|
+
* @param ctx - 宿主 Context。
|
|
483
|
+
* @param enabled - 是否启用同回合围栏修正。
|
|
484
|
+
* @param renderUiToolEnabled - 是否已注册 `render_ui` 工具。
|
|
268
485
|
*/
|
|
269
|
-
export function installFenceFeedback(ctx: Context, enabled: boolean): void {
|
|
270
|
-
if (!enabled) return
|
|
486
|
+
export function installFenceFeedback(ctx: Context, enabled: boolean, renderUiToolEnabled = true): void {
|
|
271
487
|
const sessions = new Map<string, SessionFeedback>()
|
|
272
488
|
const stateOf = (sessionId: string): SessionFeedback => {
|
|
273
489
|
let state = sessions.get(sessionId)
|
|
274
490
|
if (state === undefined) {
|
|
275
|
-
state = {
|
|
491
|
+
state = {
|
|
492
|
+
text: '',
|
|
493
|
+
validatedThisTurn: false,
|
|
494
|
+
isSubagent: false,
|
|
495
|
+
deliveredThisTurn: false,
|
|
496
|
+
pendingRenders: new Set(),
|
|
497
|
+
correctedSpec: new Set(),
|
|
498
|
+
deliveryRemindedTurns: new Set(),
|
|
499
|
+
correctionsThisTurn: 0,
|
|
500
|
+
correctionsTurn: undefined,
|
|
501
|
+
currentTurn: undefined,
|
|
502
|
+
accountedCorrectionMessageIds: new Set(),
|
|
503
|
+
}
|
|
276
504
|
sessions.set(sessionId, state)
|
|
277
505
|
}
|
|
278
506
|
return state
|
|
@@ -282,53 +510,156 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void {
|
|
|
282
510
|
sessions.delete(String(session.id))
|
|
283
511
|
})
|
|
284
512
|
|
|
285
|
-
|
|
513
|
+
const resetTurnState = (state: SessionFeedback, turn: number | undefined): void => {
|
|
514
|
+
state.text = ''
|
|
515
|
+
state.validatedThisTurn = false
|
|
516
|
+
state.deliveredThisTurn = false
|
|
517
|
+
state.pendingRenders.clear()
|
|
518
|
+
state.currentTurn = turn
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const observeEvent = (session: Session, event: SessionEvent): void => {
|
|
286
522
|
const sessionId = String(session.id)
|
|
523
|
+
if (event.type === 'turn/start') {
|
|
524
|
+
// The formal turn boundary: whatever happened in the previous turn is
|
|
525
|
+
// settled and this turn starts clean. The `user/message` reset below is
|
|
526
|
+
// only a fallback for direct prompts.
|
|
527
|
+
const state = stateOf(sessionId)
|
|
528
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
529
|
+
// 去重只约束当前回合,新一轮仍可修正相同错误。
|
|
530
|
+
state.correctedSpec.clear()
|
|
531
|
+
resetTurnState(state, (event.data as { turn?: number }).turn)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
287
534
|
if (event.type === 'assistant/message') {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
535
|
+
const content = (event.data as { message?: { content?: unknown } }).message?.content
|
|
536
|
+
const text = textOfContent(content)
|
|
537
|
+
const state = stateOf(sessionId)
|
|
538
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
539
|
+
// Fences are read from the BODY only: a draft in the reasoning block is not
|
|
540
|
+
// a delivery and must not become one.
|
|
541
|
+
state.text = extractDshUiFences(text).length > 0 ? text : ''
|
|
542
|
+
// Delivery is sticky for the turn: once a body text exists, the turn has
|
|
543
|
+
// answered and must never be corrected into publishing again. A
|
|
544
|
+
// `render_ui` call in the content proves nothing here — its result does.
|
|
545
|
+
state.deliveredThisTurn = state.deliveredThisTurn || deliveredBodyText(content)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (event.type === 'tool/call') {
|
|
549
|
+
const data = event.data as { name?: unknown; callId?: unknown }
|
|
550
|
+
const state = stateOf(sessionId)
|
|
551
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
552
|
+
// validate_dsh_ui is the FORMAL signal that this turn is GenUI-related.
|
|
553
|
+
if (data.name === 'validate_dsh_ui') state.validatedThisTurn = true
|
|
554
|
+
// The result — not the call — decides whether render_ui delivered.
|
|
555
|
+
if (data.name === DELIVERY_TOOL && typeof data.callId === 'string') {
|
|
556
|
+
state.pendingRenders.add(data.callId)
|
|
557
|
+
}
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
if (event.type === 'tool/result') {
|
|
561
|
+
const data = event.data as { message?: unknown; error?: unknown }
|
|
562
|
+
const result = observedToolResult(data.message)
|
|
563
|
+
if (result === null) return
|
|
564
|
+
const state = stateOf(sessionId)
|
|
565
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
566
|
+
if (!state.pendingRenders.delete(result.callId)) return
|
|
567
|
+
if (data.error === undefined && result.isError !== true && renderResultStatus(result.content) === 'rendered') {
|
|
568
|
+
state.deliveredThisTurn = true
|
|
293
569
|
}
|
|
294
|
-
stateOf(sessionId).text = text
|
|
295
570
|
return
|
|
296
571
|
}
|
|
297
572
|
if (event.type !== 'user/message') return
|
|
298
|
-
const data = event.data as { content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
|
|
573
|
+
const data = event.data as { id?: unknown; content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
|
|
299
574
|
if (isFeedbackSource(data.source)) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const fingerprints = markersIn(textOfContent(data.content))
|
|
303
|
-
if (fingerprints.length === 0) return
|
|
575
|
+
const markers = feedbackMarkersIn(textOfContent(data.content))
|
|
576
|
+
if (markers.renderFingerprints.length === 0 && markers.deliveryTurns.length === 0) return
|
|
304
577
|
const state = stateOf(sessionId)
|
|
305
|
-
|
|
578
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
579
|
+
for (const fingerprint of markers.renderFingerprints) state.correctedSpec.add(fingerprint)
|
|
580
|
+
for (const turn of markers.deliveryTurns) state.deliveryRemindedTurns.add(turn)
|
|
581
|
+
if (typeof data.id === 'string' && !state.accountedCorrectionMessageIds.has(data.id)) {
|
|
582
|
+
state.accountedCorrectionMessageIds.add(data.id)
|
|
583
|
+
const turn = markers.deliveryTurns[0] ?? state.currentTurn
|
|
584
|
+
if (turn !== undefined) accountReplayedCorrection(state, turn)
|
|
585
|
+
}
|
|
306
586
|
return
|
|
307
587
|
}
|
|
308
|
-
// A
|
|
588
|
+
// A DIRECT human prompt starts a new turn: the previous reply is settled.
|
|
589
|
+
// Synthetic context that rides the same user surface — `agent.inject()`
|
|
590
|
+
// notices, team member messages — carries a different source kind and
|
|
591
|
+
// arrives MID-TURN: resetting here used to wipe the turn's validation and
|
|
592
|
+
// delivery state before the boundary could use it.
|
|
593
|
+
if (data.source?.kind !== 'user') return
|
|
309
594
|
const state = sessions.get(sessionId)
|
|
310
|
-
if (state !== undefined)
|
|
595
|
+
if (state !== undefined) {
|
|
596
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
597
|
+
// turn/start 先于用户消息;清掉正文状态时保留正式回合身份。
|
|
598
|
+
resetTurnState(state, state.currentTurn)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const restoreHistory = (session: Session): void => {
|
|
603
|
+
if (sessions.has(String(session.id))) return
|
|
604
|
+
for (const event of session.snapshotEvents()) observeEvent(session, event)
|
|
605
|
+
}
|
|
606
|
+
ctx.on('session/event', (session, event) => {
|
|
607
|
+
// 服务注入是异步的;首条实时事件也先接管历史,避免丢失旧纠错次数。
|
|
608
|
+
restoreHistory(session)
|
|
609
|
+
observeEvent(session, event)
|
|
311
610
|
})
|
|
611
|
+
ctx.on('session/created', restoreHistory)
|
|
612
|
+
const restoreSessions = (sessionCtx: Context): void => {
|
|
613
|
+
const store = sessionCtx.reflect.get('sessions') as SessionStore | undefined
|
|
614
|
+
if (store === undefined) return
|
|
615
|
+
for (const session of store.list()) restoreHistory(session)
|
|
616
|
+
}
|
|
617
|
+
// 宿主不重播历史;安装时同步接管已有会话,异步注入负责晚到的服务。
|
|
618
|
+
restoreSessions(ctx)
|
|
619
|
+
ctx.inject(['sessions'], restoreSessions)
|
|
620
|
+
|
|
621
|
+
ctx.on('llm/stream', (options, next) => {
|
|
622
|
+
if (options.sessionId === undefined || options.purpose !== undefined) return next()
|
|
623
|
+
const state = sessions.get(String(options.sessionId))
|
|
624
|
+
if (state?.validatedThisTurn !== true || state.isSubagent) return next()
|
|
625
|
+
return retryReasoningOnlyGenuiStream(options, next())
|
|
626
|
+
}, { global: true })
|
|
312
627
|
|
|
313
628
|
ctx.on('agent/turn-stopping', ({ agent, turn, signal }): void => {
|
|
629
|
+
if (!enabled) return
|
|
314
630
|
// A child session's fence belongs to a parent reply, and an aborted turn is
|
|
315
631
|
// on its way out: never steer into either.
|
|
316
632
|
if (agent.session.header.parentSession !== undefined) return
|
|
317
633
|
const state = sessions.get(String(agent.session.id))
|
|
318
634
|
if (state === undefined) return
|
|
635
|
+
state.currentTurn = turn
|
|
636
|
+
// A render_ui result still outstanding could deliver (or fail) after this
|
|
637
|
+
// boundary fires: steering now would race the late result, so stay silent
|
|
638
|
+
// and let the next boundary decide on settled facts.
|
|
639
|
+
if (state.pendingRenders.size > 0) return
|
|
640
|
+
const usedThisTurn = state.correctionsTurn === turn ? state.correctionsThisTurn : 0
|
|
319
641
|
const plan = planFenceFeedback({
|
|
320
642
|
text: state.text,
|
|
321
643
|
turn,
|
|
322
|
-
|
|
323
|
-
|
|
644
|
+
correctedSpec: state.correctedSpec,
|
|
645
|
+
deliveryRemindedTurns: state.deliveryRemindedTurns,
|
|
646
|
+
validatedThisTurn: state.validatedThisTurn,
|
|
647
|
+
deliveredThisTurn: state.deliveredThisTurn,
|
|
324
648
|
aborted: signal.aborted,
|
|
649
|
+
correctionsThisTurn: usedThisTurn,
|
|
650
|
+
correctionsTurn: state.correctionsTurn,
|
|
651
|
+
renderUiToolEnabled,
|
|
325
652
|
})
|
|
326
653
|
if (plan === null) return
|
|
327
654
|
// Account BEFORE sending: a re-entrant boundary must not deliver twice.
|
|
328
|
-
for (const fingerprint of plan.fingerprints) state.
|
|
329
|
-
|
|
655
|
+
for (const fingerprint of plan.fingerprints) state.correctedSpec.add(fingerprint)
|
|
656
|
+
if (plan.kind === 'delivery') state.deliveryRemindedTurns.add(plan.turn)
|
|
657
|
+
state.correctionsTurn = plan.turn
|
|
658
|
+
state.correctionsThisTurn = usedThisTurn + 1
|
|
330
659
|
try {
|
|
331
|
-
|
|
660
|
+
const message = createFeedbackMessage(plan.text, agent.session.header.version)
|
|
661
|
+
state.accountedCorrectionMessageIds.add(message.id)
|
|
662
|
+
agent.steer(message)
|
|
332
663
|
} catch (error) {
|
|
333
664
|
ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`)
|
|
334
665
|
}
|