feat(web): declare the remaining context forms on every shipped producer

Four values complete the vocabulary, so the opaque body is reached only by
producers that genuinely promise no shape.

`snapshot` — current state a later snapshot supersedes. system-prompt now
exposes `renderContextSections()`, the named contributions
`renderContextSnapshot()` already joins for the model, so the body attributes
each part to the subsystem that produced it instead of re-splitting joined
prose. The runtime snapshot, time-context, and tmux-context declare it.

`notice` — a one-off account of what just happened, declared by tool-tasks,
goal state changes, tool-goal wrap-up, plan-mode switches, and
repeat-tool-guard. Its `summary` rides the COLLAPSED row: these five are the
majority of shipped producers and none of them needs expanding to be read.
The task summary bounds itself because its inputs are unbounded caller text.

`relay` — a message another agent addressed to this one; both subagent
sources declare it and the body names the sender above what it said.

`recall` — material lifted from another session's log. session-reference
needed no new field: its references already record retained and omitted
counts and the truncation flag, which the body shows first, because recalled
context is bounded on the way in.

`ContextFormed` is now discriminated by `form`, so a producer cannot declare
a shape without the facts that shape is presented from — a notice without its
summary, or a snapshot without its sections, fails to compile.

Only the two hook bridges stay opaque, by design: their content is whatever
an external program printed, so no shape can be promised for it. Unknown
kinds and unreadable records land there too.
This commit is contained in:
creatixchu
2026-08-05 16:07:04 +08:00
parent 5f34f782fc
commit b7034e4a26
132 changed files with 771 additions and 159 deletions

View File

@@ -203,7 +203,12 @@ describe('bash tool through the agent loop', () => {
expect(notice.data.content.some(
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
)).toBe(true)
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
expect(notice.data.source).toEqual({
kind: 'plugin',
plugin: 'tool-tasks',
form: 'notice',
summary: 'bash echo bg-ok [status: completed, exit code: 0]',
})
// The next turn collects the output through the generic task tool.
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))

View File

@@ -97,7 +97,7 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
* dropping the row, so a log written by a newer or foreign producer still
* renders.
*/
const KNOWN_FORMS = ['instructions', 'catalog'] as const
const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
/** One durable context form this UI version knows how to present. */
export type KnownContextForm = typeof KNOWN_FORMS[number]

View File

@@ -100,3 +100,62 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* snapshot: one titled block per contributing subsystem. */
.sections {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sectionName {
color: var(--dsw-alias-label-caption);
}
.sectionText {
margin: 0;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* relay: who sent this, above what they said. */
.relaySender {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
overflow-wrap: anywhere;
}
/* recall: one row per source session, with how much of it survived. */
.recalls {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.recall {
display: flex;
gap: 8px;
min-width: 0;
}
.recallLabel {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.recallCounts {
flex: none;
color: var(--dsw-alias-label-caption);
}

View File

@@ -345,31 +345,223 @@ export function CatalogBody({ content, source, t }: {
)
}
/** One named contribution to a runtime snapshot, as the durable source records it. */
interface SnapshotSection {
name: string
text: string
}
/** Snapshot sections read off the source, or null when the record is unusable. */
function snapshotSections(source: unknown): SnapshotSection[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['sections']
if (!Array.isArray(list)) return null
const sections: SnapshotSection[] = []
for (const item of list as readonly unknown[]) {
const section = asRecord(item)
if (section === null) return null
const name = section['name']
const text = section['text']
if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null
sections.push({ name, text })
}
return sections.length === 0 ? null : sections
}
/**
* `snapshot` form: the named contributions this snapshot assembled, in order.
*
* 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.
* @param props - Durable content, its source, and the locale seat.
* @returns The snapshot context body, or the opaque body when unreadable.
*/
export function SnapshotBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sections = snapshotSections(source)
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>
)
}
/**
* `notice` form: what just happened, with the model-facing text beneath it.
*
* The one-line account also rides the collapsed row ({@link contextBody}), so a
* notice is usually readable without expanding at all.
* @param props - Durable content, its source, and the locale seat.
* @returns The notice context body.
*/
export function NoticeBody({ content, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return <ModelFacingContent content={content} t={t} />
}
/**
* `relay` form: which agent sent this, then what it said.
*
* The sender is an opaque session id; it is shown as provenance rather than a
* label, because this client cannot resolve it to a title.
* @param props - Durable content, its source, and the locale seat.
* @returns The relay context body.
*/
export function RelayBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sender = asRecord(source)?.['senderSessionId']
if (typeof sender !== 'string' || sender === '') {
return <OpaqueBody content={content} source={source} t={t} />
}
return (
<>
<p className={css.relaySender} data-context-relay-sender>
{t('message.context.relay.from', { session: sender })}
</p>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** One recalled session, as the durable source records it. */
interface RecalledSession {
label: string
retained: number | null
omitted: number | null
truncated: boolean
}
/** Recalled sessions read off the source, or null when the record is unusable. */
function recalledSessions(source: unknown): RecalledSession[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['references']
if (!Array.isArray(list)) return null
const sessions: RecalledSession[] = []
for (const item of list as readonly unknown[]) {
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,
})
}
return sessions.length === 0 ? null : sessions
}
/**
* `recall` form: which sessions this material came from and how much of each
* survived the read, then the material itself.
*
* Completeness is the fact a reader needs first: recalled context is bounded on
* the way in, so a card that hid the omitted count would overstate what the
* model received.
* @param props - Durable content, its source, and the locale seat.
* @returns The recall context body, or the opaque body when unreadable.
*/
export function RecallBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sessions = recalledSessions(source)
if (sessions === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<ul className={css.recalls} data-context-recalls>
{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>
)}
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The one-line account a `notice` puts on its collapsed row, when it records one. */
function noticeSummary(source: unknown): string | null {
const summary = asRecord(source)?.['summary']
return typeof summary === 'string' && summary !== '' ? summary : null
}
/**
* Choose the body for one context node.
*
* Returns the form the body actually rendered as, which is not always the
* declared one: a declared form whose fields are unreadable falls back to
* opaque, and the caller labels the row with what it really shows.
* `summary` is the collapsed row's one-line account, which only a `notice`
* records: its whole point is being readable without expanding.
* @param form - the producer-declared form projected onto the node.
* @param props - durable content, its source, and the locale seat.
* @returns the rendered form (null for opaque) and its body.
* @returns the rendered form (null for opaque), its collapsed summary, and its body.
*/
export function contextBody(
form: ContextMessageNode['form'],
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
): { rendered: KnownContextForm | null; body: ReactNode } {
): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } {
const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> }
switch (form) {
case 'instructions':
return instructionChanges(props.source) === null
? { rendered: null, body: <OpaqueBody {...props} /> }
: { rendered: 'instructions', body: <InstructionsBody {...props} /> }
? opaque
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
case 'catalog':
return catalogEntries(props.source) === null
? { rendered: null, body: <OpaqueBody {...props} /> }
: { rendered: 'catalog', body: <CatalogBody {...props} /> }
? opaque
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
case 'snapshot':
return snapshotSections(props.source) === null
? opaque
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
case 'notice': {
const summary = noticeSummary(props.source)
return summary === null
? opaque
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
case null:
return { rendered: null, body: <OpaqueBody {...props} /> }
return opaque
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
KnownContextForm here rather than letting it degrade to opaque silently. */
default: {

View File

@@ -24,6 +24,18 @@
}
.source {
flex: none;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A notice's one-line account: the reason it rarely needs expanding. */
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;

View File

@@ -33,7 +33,7 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
const [open, setOpen] = useState(false)
// Resolved rather than declared: a form whose fields are unreadable renders
// the opaque body, and the marker must say what the row actually shows.
const { rendered, body } = contextBody(form, { content, source, t })
const { rendered, summary, body } = contextBody(form, { content, source, t })
return (
<DisclosureRow
@@ -48,6 +48,12 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
<>
<span className={css.sep} aria-hidden />
<span className={css.source} data-context-source>{provenance.label}</span>
{summary !== null && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary} data-context-summary>{summary}</span>
</>
)}
</>
)}
keepContentWhenOpen

View File

@@ -61,6 +61,9 @@ export const zh = {
'message.context.instructions.removed': '已移除',
'message.context.catalog.replaced': '替换目录',
'message.context.catalog.more': '…还有 {count} 条',
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
@@ -182,6 +185,9 @@ export const en = {
'message.context.instructions.removed': 'removed',
'message.context.catalog.replaced': 'Replacement catalog',
'message.context.catalog.more': '… {count} more',
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',

View File

@@ -562,6 +562,106 @@ describe('MessageItem arms', () => {
expect(fields).toEqual(['plugin', 'form'])
})
it('the snapshot form attributes each part to the subsystem that produced it', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
source: {
kind: 'plugin',
plugin: '@deepseek-ai/dsh-system-prompt',
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
},
provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
})
it('a notice puts its account on the collapsed row', () => {
// The whole point of the form: readable without expanding.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'background task bash-1 finished.' }],
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' },
provenance: { role: 'inject', label: 'tool-tasks' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')?.textContent)
.toBe('bash pnpm test [status: completed]')
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
})
it('a notice without its account falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' },
provenance: { role: 'inject', label: 'tool-tasks' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ }))
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('a relay names the agent that sent it above what it said', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'child report body' }],
source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' },
provenance: { role: 'inject', label: 'subagent-report' },
form: 'relay',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ }))
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
})
it('a recall reports how much of each source session survived the read', () => {
// Recalled context is bounded on the way in, so hiding the omitted count
// would overstate what the model received.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'recalled material' }],
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
],
},
provenance: { role: 'recall', label: '重构 loader, 修 CI' },
form: 'recall',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
})
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,

View File

@@ -199,6 +199,7 @@ export class SessionReferenceService extends Service {
const prompt = renderPrompt(rendered.map(source => source.data))
const source: SessionReferenceSource = {
kind: 'session-reference',
form: 'recall',
version: 1,
references: rendered.map((source, index) => ({
sessionId: source.data.sessionId,

View File

@@ -6,6 +6,8 @@ import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
/** Durable provenance for one prepared cross-session context. */
export interface SessionReferenceSource {
kind: 'session-reference'
/** Material lifted out of another session's log (`recall` context form). */
form: 'recall'
version: 1
references: {
sessionId: string

View File

@@ -174,6 +174,10 @@ export function apply(ctx: Context, config: Config): void {
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
agent.inject(createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }))
const text = renderText(now, turn, step, previous, formatter, resolvedTimeZone)
agent.inject(createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
}))
}, { prepend: true })
}

View File

@@ -154,7 +154,19 @@ describe('durable step context', () => {
const event = session.events.at(-1)
expect(event?.type).toBe('user/message')
if (event?.type !== 'user/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
// The reading is a `snapshot`-form context: one named contribution whose
// text is exactly what the model read, so a consumer attributes it without
// re-splitting prose.
expect(event.data.source).toEqual({
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: [{
name: 'time-context',
text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
}],
})
expect(event.surfaceOp).toBe('append')
})

View File

@@ -233,9 +233,10 @@ export function apply(ctx: Context, config: Config): void {
if (location === undefined) return
const state = renderState(location)
if (previous !== undefined && previous.state === state) return
const text = renderReading(location, turn)
agent.inject(createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
}))
}, { prepend: true })
}

View File

@@ -163,7 +163,14 @@ describe('tmux-context injection', () => {
])
const event = session.events.at(-1)
if (event?.type !== 'user/message') throw new Error('missing tmux context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' })
// `snapshot` form: one named contribution carrying exactly the reading the
// model saw, so a consumer attributes it without re-splitting prose.
expect(event.data.source).toMatchObject({
kind: 'plugin',
plugin: 'tmux-context',
form: 'snapshot',
sections: [{ name: 'tmux-context' }],
})
expect(event.surfaceOp).toBe('append')
})

View File

@@ -1810,12 +1810,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
},
{
name: 'ContextForm',
declaration: 'export type ContextForm = \'instructions\' | \'catalog\';',
name: 'ContextFormed',
declaration: 'export type ContextFormed = {\n readonly form?: never;\n} | {\n readonly form: \'instructions\';\n} | {\n readonly form: \'catalog\';\n} | {\n readonly form: \'snapshot\';\n readonly sections: readonly ContextSnapshotSection[];\n} | {\n readonly form: \'notice\';\n readonly summary: string;\n} | {\n readonly form: \'relay\';\n} | {\n readonly form: \'recall\';\n};',
},
{
name: 'ContextFormed',
declaration: 'export interface ContextFormed {\n readonly form?: ContextForm;\n}',
name: 'ContextSnapshotSection',
declaration: 'export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n}',
},
{
name: 'ContinuableCreateRequest',

View File

@@ -46,10 +46,12 @@ import {
llmRetryPolicyOf,
markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type {
ContextSnapshotSection, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy,
} 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 { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { renderContextSections, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
@@ -105,7 +107,11 @@ function retainedRuntimeContext(session: Session): { found: boolean; text: strin
}
/** Append a full current snapshot only when it changed or compaction removed it. */
function materializeRuntimeContext(session: Session, current: string): void {
function materializeRuntimeContext(
session: Session,
current: string,
sections: readonly ContextSnapshotSection[],
): void {
const previous = retainedRuntimeContext(session)
if (!previous.found && current.length === 0) {
const compactedPriorSnapshot = session.surface.replaceGeneration > 0
@@ -116,7 +122,10 @@ function materializeRuntimeContext(session: Session, current: string): void {
if (previous.text === snapshot) return
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: snapshot }],
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
// The cleared marker has no contributions left to attribute.
source: sections.length === 0
? { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE }
: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE, form: 'snapshot', sections },
}), { surfaceOp: 'append' })
}
@@ -691,7 +700,7 @@ 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))
materializeRuntimeContext(session, renderContextSnapshot(assembly), renderContextSections(assembly))
// Commit the exact pending batch only after every asynchronous
// pre-request contribution succeeded. Input accepted after this splice

View File

@@ -8,7 +8,7 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
interface Context {
@@ -208,14 +208,26 @@ 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 = assembly.contexts
.map(context => interpolate(context, assembly.variables, 'context'))
.filter(text => text.length > 0)
.join('\n\n')
const body = renderContextSections(assembly).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}`
}
/**
* The same snapshot, kept as the named contributions it was assembled from.
*
* {@link renderContextSnapshot} joins these for the model; a consumer that
* presents the snapshot uses them to attribute each part to the subsystem that
* contributed it, without re-splitting the joined prose.
* @param assembly - the assembly whose contexts and variables to render.
* @returns one entry per contributing context that rendered to non-empty text.
*/
export function renderContextSections(assembly: PromptAssembly): ContextSnapshotSection[] {
return assembly.contexts
.map(context => ({ name: context.name, text: interpolate(context, assembly.variables, 'context') }))
.filter(section => section.text.length > 0)
}
/** Interpolate one section or context and attribute diagnostics to its owning input. */
function interpolate(
input: AssembledSection | AssembledContext,

View File

@@ -61,6 +61,13 @@ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
export interface 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. */

View File

@@ -23,7 +23,7 @@ import {
goalChangeRef,
} from './fold.ts'
import type { GoalFoldState } from './fold.ts'
import { renderGoalChange } from './render.ts'
import { goalChangeSummary, renderGoalChange } from './render.ts'
import {
GOAL_CHANGE_VERSION,
GoalError,
@@ -568,7 +568,15 @@ export class GoalService extends Service {
try {
agent.inject(createUserMessage({
content: renderGoalChange(change),
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
source: {
kind: 'goal',
goalId: ref.id,
revision: ref.revision,
round: 0,
change,
form: 'notice',
summary: goalChangeSummary(change),
},
}))
} catch (error: unknown) {
const index = cache.pending.indexOf(pending)

View File

@@ -3,6 +3,18 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GoalChangeMeta } from './domain.ts'
/**
* One-line account of a goal mutation for the `notice` form's collapsed row.
* @param change - durable goal change carried by the message source.
* @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'
? change.operation
: `${change.operation}: ${change.goal.objective}`
}
/**
* Render a complete goal snapshot or clear tombstone without hidden prose.
* @param change - durable goal change carried by the message source.

View File

@@ -315,7 +315,12 @@ export function apply(ctx: Context, config: Config): void {
content: args.action === 'complete'
? renderWrapupContext(goal.objective)
: renderWrapupContext(goal.objective, args.blocked_reason as string),
source: { kind: 'plugin', plugin: 'tool-goal' },
source: {
kind: 'plugin',
plugin: 'tool-goal',
form: 'notice',
summary: `${args.action as string}: ${goal.objective}`,
},
}))
}
return Promise.resolve(goalValue(goal))

View File

@@ -372,7 +372,12 @@ describe('goal tool state transitions', () => {
expect(complete.concludesTurn).toBeUndefined()
const contexts = complete.additionalContexts ?? []
expect(contexts).toHaveLength(1)
expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' })
expect(contexts[0]?.source).toEqual({
kind: 'plugin',
plugin: 'tool-goal',
form: 'notice',
summary: 'complete: pause cleanly',
})
const block = contexts[0]?.content[0]
if (block?.type !== 'text') throw new Error('expected one text wrap-up block')
expect(block.text).toContain('<goal_complete>')

View File

@@ -200,7 +200,10 @@ export function apply(ctx: Context, config: Config): void {
const text = count === thresholds[0]
? GENTLE_REMINDER
: detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars))
return createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })
return createUserMessage({
content: [{ type: 'text', text }],
source: { ...PLUGIN_SOURCE, form: 'notice', summary: `${exec.name} × ${count}` },
})
}
// Observe-and-enrich, never veto: count first (state advances regardless of

View File

@@ -45,7 +45,14 @@ function reminders(agent: Agent): { text: string; source: unknown }[] {
}))
}
const GUARD_SOURCE = { kind: 'plugin', plugin: 'repeat-tool-guard' }
// The reminder is a `notice`-form context; its summary names the repeated
// call so a reader sees it without expanding the row.
const guardSource = (tool: string, count: number) => ({
kind: 'plugin',
plugin: 'repeat-tool-guard',
form: 'notice',
summary: `${tool} × ${count}`,
})
describe('threshold escalation', () => {
it('reminds gently at the first default threshold (3) and in detail at the second (5)', async () => {
@@ -62,11 +69,11 @@ describe('threshold escalation', () => {
const found = reminders(agent)
expect(found).toHaveLength(2)
expect(found[0]!.text).toContain('repeating the exact same tool call')
expect(found[0]!.source).toEqual(GUARD_SOURCE)
expect(found[0]!.source).toEqual(guardSource('probe', 3))
expect(found[1]!.text).toContain('consecutive_calls: 5')
expect(found[1]!.text).toContain('- tool: probe')
expect(found[1]!.text).toContain('{"q":"same"}')
expect(found[1]!.source).toEqual(GUARD_SOURCE)
expect(found[1]!.source).toEqual(guardSource('probe', 5))
})
it('keys the gentle text to thresholds[0], not the literal 3', async () => {
@@ -327,7 +334,7 @@ describe('fold onto the downstream decision', () => {
expect(found[0]!.text).toBe('downstream-ctx')
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
expect(found[1]!.text).toContain('repeating the exact same tool call')
expect(found[1]!.source).toEqual(GUARD_SOURCE)
expect(found[1]!.source).toEqual(guardSource('probe', 2))
expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })
// The block's feedback reached the tool result unchanged.
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')

View File

@@ -50,12 +50,49 @@ export type ContextForm =
| 'instructions'
/** A catalog of items available in this session, republished as it changes. */
| 'catalog'
/** Current state, where a later snapshot from the same producer supersedes an earlier one. */
| 'snapshot'
/** A one-off account of something that just happened; it supersedes nothing. */
| 'notice'
/** A message another agent addressed to this one. */
| 'relay'
/** Material lifted out of another session's log, possibly reduced on the way in. */
| 'recall'
/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */
export interface ContextFormed {
readonly form?: ContextForm
/** One named contribution to a `snapshot`-form context, in assembly order. */
export interface ContextSnapshotSection {
/** The contributing subsystem's name. */
readonly name: string
/** That contribution's model-facing text, exactly as assembled. */
readonly text: string
}
/**
* Producer-declared {@link ContextForm} and the fields that form requires,
* mixed into the source shapes that carry one.
*
* Discriminated by `form` so a producer cannot declare a shape without the
* facts that shape is presented from: a `notice` must record its one-line
* account, a `snapshot` its sections. Omitting `form` stays valid — an
* undeclared context is the documented default.
*/
export type ContextFormed =
| { readonly form?: never }
| { readonly form: 'instructions' }
| { readonly form: 'catalog' }
| {
readonly form: 'snapshot'
/** The named contributions this snapshot assembled, in order. */
readonly sections: readonly ContextSnapshotSection[]
}
| {
readonly form: 'notice'
/** One-line account of what happened, shown without expanding the row. */
readonly summary: string
}
| { readonly form: 'relay' }
| { readonly form: 'recall' }
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind`s.

View File

@@ -454,7 +454,8 @@ export class PlanModeService extends Service {
: 'The user switched this session back to the default mode.'
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'plan-mode' },
// The narration is already one sentence, so it is its own summary.
source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text },
}), { surfaceOp: 'append' })
}
}

View File

@@ -47,6 +47,8 @@ import type SubagentActivationSetupRegistry from './activation-setup-registry.ts
/** Attribution for a model coordinator's follow-up to one of its children. */
export interface CoordinatorMessageSource {
readonly kind: 'coordinator'
/** A message another agent addressed to this one (`relay` context form). */
readonly form: 'relay'
/** Session id of the agent whose tool call produced the follow-up. */
readonly senderSessionId: SessionId
}
@@ -54,6 +56,8 @@ export interface CoordinatorMessageSource {
/** Durable attribution for a continuable child's explicit parent report. */
export interface SubagentReportMessageSource {
readonly kind: 'subagent-report'
/** A message another agent addressed to this one (`relay` context form). */
readonly form: 'relay'
/** Session id of the reporting child. */
readonly senderSessionId: SessionId
}
@@ -481,6 +485,7 @@ export class SubagentContinuationManager {
],
source: {
kind: 'subagent-report' as const,
form: 'relay' as const,
senderSessionId: activation.childId,
},
})

View File

@@ -66,7 +66,7 @@ export function apply(ctx: Context): void {
SessionId(args.subagent_id),
message,
{
source: { kind: 'coordinator', senderSessionId: parent.id },
source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id },
signal: exec.signal,
},
)

View File

@@ -102,6 +102,7 @@ describe('dsh-tool-subagent-control', () => {
// Durable provenance records the calling agent without granting authority.
expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
kind: 'coordinator',
form: 'relay',
senderSessionId: parent.id,
})
})

View File

@@ -114,6 +114,26 @@ 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}.
*/
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)}`
}
function fitCompletionNotice(snapshot: TaskSnapshot): string {
const prefix = `background task ${snapshot.id}`
const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}`
@@ -230,7 +250,12 @@ export function apply(ctx: Context, config: Config): void {
type: 'text',
text: fitCompletionNotice(snapshot),
}],
source: { kind: 'plugin', plugin: 'tool-tasks' },
source: {
kind: 'plugin',
plugin: 'tool-tasks',
form: 'notice',
summary: completionSummary(snapshot),
},
}))
})

View File

@@ -460,7 +460,12 @@ describe('completion notices', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
source: { kind: 'plugin', plugin: 'tool-tasks' },
source: {
kind: 'plugin',
plugin: 'tool-tasks',
form: 'notice',
summary: 'bash pnpm test [status: completed, exit code: 0]',
},
})
})
@@ -484,7 +489,14 @@ describe('completion notices', () => {
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }],
source: { kind: 'plugin', plugin: 'tool-tasks' },
// The label and status detail are unbounded caller text, so the durable
// one-line account caps itself rather than committing their full length.
source: {
kind: 'plugin',
plugin: 'tool-tasks',
form: 'notice',
summary: `subagent ${'x'.repeat(110)}`,
},
},
)