fix(web): address the review round on the remaining context forms

- `relay` resolves its sender in `contextBody` like every other form. It was
  the one shape whose marker could claim a form the body did not render: an
  unreadable sender fell back inside the body while the row still said relay,
  contradicting the contract this PR's own note states.
- `recall` requires the retained, omitted, and truncated fields. Completeness
  is what the card exists to report, so a reference that cannot state it is
  not a readable recall — showing the label alone presents a confident card
  over unknown loss.
- The snapshot body states the supersession its producer framing line carries.
  That line is the one part of the model-facing text no section contains, and
  unlike an instruction context's `<system-reminder>` it states the form's own
  semantics rather than wrapping content.
- `GoalMessageSource` is a discriminated pair, so `{ form: 'notice' }` without
  its account no longer compiles. The guarantee this PR claims now holds at
  that seam too, not only through `ContextFormed` on plugin sources.
- Goal and tool-goal summaries are bounded by a shared `boundContextSummary`,
  which tool-tasks now uses as well. A goal objective is unbounded caller text
  in exactly the way a task label is.
- The runtime snapshot interpolates once per request: agent-loop renders the
  sections and joins them through `joinContextSections`.
- Every form's fallback branch is pinned, not only the notice one.
This commit is contained in:
creatixchu
2026-08-05 17:39:50 +08:00
parent bb59526598
commit e7f2005ec7
16 changed files with 160 additions and 84 deletions

View File

@@ -374,6 +374,13 @@ function snapshotSections(source: unknown): SnapshotSection[] | null {
* The sections are the same bytes the model read, split at the boundaries the
* producer assembled them on, so a reader sees which subsystem contributed
* which state instead of one undifferentiated wall.
*
* One sentence of the model-facing text is NOT in any section: the producer's
* framing line declaring that this snapshot supersedes earlier ones. Unlike the
* `<system-reminder>` wrapper an instruction context carries — which wraps
* content and cannot be separated from it — that line states the form's own
* semantics, so the body states them as a caption instead of reprinting the
* joined prose beside the sections it was split from.
* @param props - Durable content, its source, and the locale seat.
* @returns The snapshot context body, or the opaque body when unreadable.
*/
@@ -383,16 +390,22 @@ export function SnapshotBody({ content, source, t }: {
t: Translate
}): ReactNode {
const sections = snapshotSections(source)
/* v8 ignore next -- contextBody reads the sections before choosing this body. */
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
<>
<p className={css.catalogNotice} data-context-snapshot-supersedes>
{t('message.context.snapshot.supersedes')}
</p>
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
</>
)
}
@@ -425,10 +438,9 @@ export function RelayBody({ content, source, t }: {
source: unknown
t: Translate
}): ReactNode {
const sender = asRecord(source)?.['senderSessionId']
if (typeof sender !== 'string' || sender === '') {
return <OpaqueBody content={content} source={source} t={t} />
}
const sender = relaySender(source)
/* v8 ignore next -- contextBody resolves the sender before choosing this body. */
if (sender === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.relaySender} data-context-relay-sender>
@@ -439,11 +451,17 @@ export function RelayBody({ content, source, t }: {
)
}
/** The sending agent's session id, or null when the record does not name one. */
function relaySender(source: unknown): string | null {
const sender = asRecord(source)?.['senderSessionId']
return typeof sender === 'string' && sender !== '' ? sender : null
}
/** One recalled session, as the durable source records it. */
interface RecalledSession {
label: string
retained: number | null
omitted: number | null
retained: number
omitted: number
truncated: boolean
}
@@ -457,15 +475,16 @@ function recalledSessions(source: unknown): RecalledSession[] | null {
const reference = asRecord(item)
if (reference === null) return null
const label = reference['label']
if (typeof label !== 'string' || label === '') return null
const retained = reference['retainedMessages']
const omitted = reference['omittedMessages']
sessions.push({
label,
retained: typeof retained === 'number' ? retained : null,
omitted: typeof omitted === 'number' ? omitted : null,
truncated: reference['truncated'] === true,
})
const truncated = reference['truncated']
// Completeness is the fact this card exists to report, so a reference that
// cannot state it is not a readable recall — showing the label alone would
// present a confident card over unknown loss.
if (typeof label !== 'string' || label === ''
|| typeof retained !== 'number' || typeof omitted !== 'number'
|| typeof truncated !== 'boolean') return null
sessions.push({ label, retained, omitted, truncated })
}
return sessions.length === 0 ? null : sessions
}
@@ -493,14 +512,12 @@ export function RecallBody({ content, source, t }: {
{sessions.map((session, index) => (
<li key={index} className={css.recall}>
<span className={css.recallLabel}>{session.label}</span>
{session.retained !== null && session.omitted !== null && (
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
)}
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
@@ -555,7 +572,9 @@ export function contextBody(
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
return relaySender(props.source) === null
? opaque
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque

View File

@@ -61,6 +61,7 @@ export const zh = {
'message.context.instructions.removed': '已移除',
'message.context.catalog.replaced': '替换目录',
'message.context.catalog.more': '…还有 {count} 条',
'message.context.snapshot.supersedes': '取代先前的快照',
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
@@ -185,6 +186,7 @@ export const en = {
'message.context.instructions.removed': 'removed',
'message.context.catalog.replaced': 'Replacement catalog',
'message.context.catalog.more': '… {count} more',
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',

View File

@@ -617,6 +617,45 @@ describe('MessageItem arms', () => {
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('each form falls back to the opaque body when its required facts are unreadable', () => {
// The fallback chain is the load-bearing wall: every dedicated form must
// reach it, and the row marker must not claim a form that did not render.
const cases = [
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
] as const
for (const { form, source, label } of cases) {
cleanup()
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
source, provenance: { role: 'inject', label }, form,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
}
})
it('a snapshot states the supersession its framing line carries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
provenance: { role: 'inject', label: 'plugin' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
.toBe('取代先前的快照')
})
it('a relay names the agent that sent it above what it said', () => {
const view = render(
<MessageItem t={t} node={{

View File

@@ -51,7 +51,7 @@ import type {
} from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import { renderContextSections, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
@@ -700,7 +700,8 @@ export class ReactLoopAgent implements Agent {
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const system = renderPrompt(assembly)
materializeRuntimeContext(session, renderContextSnapshot(assembly), renderContextSections(assembly))
const sections = renderContextSections(assembly)
materializeRuntimeContext(session, joinContextSections(sections), sections)
// Commit the exact pending batch only after every asynchronous
// pre-request contribution succeeded. Input accepted after this splice

View File

@@ -208,7 +208,19 @@ export function renderPrompt(assembly: PromptAssembly): string {
* @returns the current full snapshot, or `''` when no context is active.
*/
export function renderContextSnapshot(assembly: PromptAssembly): string {
const body = renderContextSections(assembly).map(section => section.text).join('\n\n')
return joinContextSections(renderContextSections(assembly))
}
/**
* The model-facing snapshot text for an already-rendered section list.
*
* A caller that also needs the sections renders them once and joins here, so a
* request does not interpolate every context twice.
* @param sections - sections from {@link renderContextSections}.
* @returns the current full snapshot, or `''` when no context is active.
*/
export function joinContextSections(sections: readonly ContextSnapshotSection[]): string {
const body = sections.map(section => section.text).join('\n\n')
if (body.length === 0) return ''
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
}

View File

@@ -59,22 +59,20 @@ export interface GoalClearChangeMeta {
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
export interface GoalMessageSource {
export type GoalMessageSource = {
readonly kind: 'goal'
/**
* Round-zero state changes are `notice`-form contexts; a continuation round
* carries the objective forward as ordinary context and declares no form.
*/
readonly form?: 'notice'
/** Present with `form`: one-line account of the mutation. */
readonly summary?: string
readonly goalId: GoalId
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
readonly round: number
/** Complete durable mutation carried only by round-zero state-change messages. */
readonly change?: GoalChangeMeta
}
/**
* Round-zero state changes are `notice`-form contexts; a continuation round
* carries the objective forward as ordinary context and declares no form.
* Discriminated so the account cannot be omitted when the form is declared.
*/
} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never })
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {

View File

@@ -1,5 +1,6 @@
/** Model-visible rendering for durable goal mutations. */
import { boundContextSummary } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GoalChangeMeta } from './domain.ts'
@@ -9,10 +10,11 @@ import type { GoalChangeMeta } from './domain.ts'
* @returns the operation and, for a surviving goal, its objective.
*/
export function goalChangeSummary(change: GoalChangeMeta): string {
// The row header already names the producer, so the account does not repeat it.
return change.operation === 'clear'
// The row header already names the producer, so the account does not repeat
// it. The objective is unbounded caller text, so the account is bounded.
return boundContextSummary(change.operation === 'clear'
? change.operation
: `${change.operation}: ${change.goal.objective}`
: `${change.operation}: ${change.goal.objective}`)
}
/**

View File

@@ -8,7 +8,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import { boundContextSummary, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -319,7 +319,7 @@ export function apply(ctx: Context, config: Config): void {
kind: 'plugin',
plugin: 'tool-goal',
form: 'notice',
summary: `${args.action as string}: ${goal.objective}`,
summary: boundContextSummary(`${args.action as string}: ${goal.objective}`),
},
}))
}

View File

@@ -104,6 +104,24 @@ export interface MessageSourceMap {
tool: ToolMessageSource
}
/**
* Bound for a `notice` summary. The account rides a collapsed transcript row
* and is committed to the durable log, while its inputs — task labels, goal
* objectives, tool arguments — are caller text with no length of their own.
*/
export const CONTEXT_SUMMARY_MAX_CHARS = 120
/**
* Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
* @param summary - the producer's one-line account, of any length.
* @returns the account, ellipsized when it exceeds the bound.
*/
export function boundContextSummary(summary: string): string {
return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
? summary
: `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}`
}
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]

View File

@@ -8,7 +8,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-retention'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -114,24 +114,13 @@ function fitWithSuffix(
return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`
}
/**
* Bound for the durable one-line account. A notice summary rides a collapsed
* transcript row, and both the task label and its status detail are caller
* text with no length of their own, so the summary caps itself rather than
* committing unbounded prose to the log.
*/
const SUMMARY_MAX_CHARS = 120
/**
* One-line account of a settled task for the `notice` form's collapsed row.
* @param snapshot - the settled task.
* @returns its kind, label, and status, bounded to {@link SUMMARY_MAX_CHARS}.
* @returns its kind, label, and status, bounded like every notice summary.
*/
function completionSummary(snapshot: TaskSnapshot): string {
const summary = `${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`
return summary.length <= SUMMARY_MAX_CHARS
? summary
: `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}`
return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`)
}
function fitCompletionNotice(snapshot: TaskSnapshot): string {