{boundedText(run.text, t)}
+ )
+ : (
+ {t('message.context.catalog.replaced')}
} +{entry.name}
+ {entry.description}
+ + {t('message.context.catalog.more', { count: entries.length - shown.length })} +
+ )} + {/* The block union is merge-extensible: a catalog message carrying an + unknown block still shows it rather than dropping model-visible content. */} ++ {t('message.context.snapshot.supersedes')} +
++ {t('message.context.relay.from', { session: sender })} +
+{body}
+ {href === undefined ? children : renderSafeLink(href, children)}
},
img: ({ alt = '', src = '' }) => {
const imageSrc = remoteImageUrl(src)
@@ -88,10 +106,11 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
- // languages); inline code keeps the default path (the :not(pre)
- // rule styles it). While the message streams, the fence renders the
- // plain arm — retokenizing a growing fence on every chunk is quadratic
- // main-thread work; the finalize swap highlights it once.
+ // languages); inline code keeps the path (the :not(pre) rule
+ // styles it), with a safe anchor only for complete HTTP(S) values. While
+ // the message streams, the fence renders the plain arm — retokenizing a
+ // growing fence on every chunk is quadratic main-thread work; the
+ // finalize swap highlights it once.
pre: ({ children }) => {
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
@@ -126,7 +145,8 @@ const streamingComponents = buildComponents(true)
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
- * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
+ * relative links, and unsafe protocols are disabled; complete HTTP(S)
+ * inline-code values become safe external links, while absolute HTTP(S)
* images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
diff --git a/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts b/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts
new file mode 100644
index 0000000000..a185483723
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts
@@ -0,0 +1,88 @@
+/** Let asterisk strong emphasis close after punctuation when CJK prose continues without whitespace. */
+
+import { attention } from 'micromark-core-commonmark'
+import { unicodePunctuation } from 'micromark-util-character'
+import { classifyCharacter } from 'micromark-util-classify-character'
+import { codes, constants } from 'micromark-util-symbol'
+import type { Construct, Extension, State, Tokenizer } from 'micromark-util-types'
+
+interface RemarkProcessor {
+ data(): { micromarkExtensions?: Extension[] }
+}
+
+const cjkCharacter = new RegExp([
+ '\\p{Script_Extensions=Han}',
+ '\\p{Script_Extensions=Hiragana}',
+ '\\p{Script_Extensions=Katakana}',
+ '\\p{Script_Extensions=Hangul}',
+ '\\p{Script_Extensions=Bopomofo}',
+].join('|'), 'u')
+
+function isCjkCharacter(code: number | null): boolean {
+ return code !== null && code >= 0 && cjkCharacter.test(String.fromCodePoint(code))
+}
+
+const tokenizeCjkFriendlyAttention: Tokenizer = function (effects, ok, nok) {
+ const configuredAttentionMarkers = this.parser.constructs.attentionMarkers.null
+ if (configuredAttentionMarkers === undefined) {
+ throw new Error('micromark CommonMark attention markers are unavailable')
+ }
+ const attentionMarkers = configuredAttentionMarkers
+ const previous = this.previous
+ const before = classifyCharacter(previous)
+ let marker: number | null = codes.eof
+
+ return start
+
+ function start(code: number | null): State | undefined {
+ /* v8 ignore next -- this text construct is dispatched only for an asterisk. */
+ if (code !== codes.asterisk) return nok(code)
+ marker = code
+ effects.enter('attentionSequence')
+ return inside(code)
+ }
+
+ function inside(code: number | null): State | undefined {
+ if (code === marker) {
+ effects.consume(code)
+ return inside
+ }
+
+ const token = effects.exit('attentionSequence')
+ const after = classifyCharacter(code)
+ const open = !after || (after === constants.characterGroupPunctuation && Boolean(before))
+ || attentionMarkers.includes(code)
+ const commonMarkClose = !before
+ || (before === constants.characterGroupPunctuation && Boolean(after))
+ || attentionMarkers.includes(previous)
+ const markerCount = token.end.offset - token.start.offset
+ const cjkStrongClose = markerCount >= 2
+ && unicodePunctuation(previous)
+ && isCjkCharacter(code)
+ const close = commonMarkClose || cjkStrongClose
+
+ token._open = open
+ token._close = close
+ return ok(code)
+ }
+}
+
+const cjkFriendlyAttention: Construct = {
+ name: 'cjkFriendlyAttention',
+ resolveAll: attention.resolveAll,
+ tokenize: tokenizeCjkFriendlyAttention,
+}
+
+const cjkFriendlyStrong: Extension = {
+ text: { [codes.asterisk]: cjkFriendlyAttention },
+}
+
+/**
+ * Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK prose.
+ * @returns Nothing.
+ */
+export function remarkCjkFriendlyStrong(this: RemarkProcessor): undefined {
+ const data = this.data()
+ const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
+ extensions.push(cjkFriendlyStrong)
+}
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 7a858c1199..e67302a305 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Extension } from 'micromark-util-types'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { remarkCjkFriendlyStrong } from '../src/markdown/remarkCjkFriendlyStrong.ts'
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
afterEach(cleanup)
@@ -68,6 +69,104 @@ describe('MarkdownText', () => {
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
})
+ it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => {
+ const cases = [
+ ['**注意:**内容', '注意:'],
+ ['**Notice:**内容', 'Notice:'],
+ ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'],
+ ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'],
+ ['**句号。**后续', '句号。'],
+ ['**Period.**后续', 'Period.'],
+ ['**提醒!**继续', '提醒!'],
+ ['**Warning!**继续', 'Warning!'],
+ ] as const
+ const source = cases.map(([markdown]) => markdown).join('\n\n')
+
+ for (const streaming of [false, true]) {
+ const rendered = render( )
+ expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent))
+ .toEqual(cases.map(([, strong]) => strong))
+ rendered.unmount()
+ }
+ })
+
+ it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => {
+ const source = [
+ String.raw`\**注意:**内容`,
+ '`**注意:**内容`',
+ '**Notice:**text',
+ '*提醒!*继续',
+ '$**注意:**内容$',
+ '```md',
+ '**注意:**内容',
+ '```',
+ '**普通**内容',
+ '*普通*内容',
+ ].join('\n\n')
+ const { container } = render( )
+
+ expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通'])
+ expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通'])
+ expect(container.querySelector('code')?.textContent).toBe('**注意:**内容')
+ expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容')
+ expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容')
+ expect(container.textContent).toContain('**Notice:**text')
+ expect(container.textContent).toContain('*提醒!*继续')
+ expect(container.textContent).toContain('**注意:**内容')
+ })
+
+ it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => {
+ const localUrl = 'http://127.0.0.1:3199/?demo=1'
+ const remoteUrl = 'https://example.com/preview?q=one%20two#result'
+ const source = [
+ `\`${localUrl}\``,
+ `\`${remoteUrl}\``,
+ '`curl http://127.0.0.1:3199/?demo=1`',
+ '`javascript:alert(1)`',
+ '`mailto:dev@example.com`',
+ `\` ${localUrl} \``,
+ '```',
+ localUrl,
+ '```',
+ ].join('\n\n')
+ const { container } = render( )
+
+ const links = screen.getAllByRole('link')
+ expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl])
+ for (const link of links) {
+ expect(link.closest('code')).not.toBeNull()
+ expect(link.getAttribute('target')).toBe('_blank')
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
+ }
+ links[0]?.focus()
+ expect(document.activeElement).toBe(links[0])
+ expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull()
+ expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull()
+ expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull()
+ const paddedCode = [...container.querySelectorAll('code')]
+ .find(code => code.textContent === ` ${localUrl} `)
+ expect(paddedCode?.querySelector('a')).toBeNull()
+ expect(container.querySelector('pre code a')).toBeNull()
+ })
+
+ it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => {
+ const data: { micromarkExtensions?: Extension[] } = {}
+ const processor = { data: () => data }
+ remarkCjkFriendlyStrong.call(processor)
+ remarkCjkFriendlyStrong.call(processor)
+
+ expect(data.micromarkExtensions).toHaveLength(2)
+ const construct = data.micromarkExtensions?.[0]?.text?.[42]
+ const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize
+ expect(tokenizer).toBeTypeOf('function')
+ expect(() => tokenizer?.call({
+ parser: { constructs: { attentionMarkers: {} } },
+ previous: null,
+ } as never, {} as never, () => undefined, () => undefined)).toThrow(
+ 'micromark CommonMark attention markers are unavailable',
+ )
+ })
+
it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
const { container, unmount } = render( )
diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts
index cce16df0a9..e608b9fd68 100644
--- a/packages/client/ui-trajectory/tests/context-branches.spec.ts
+++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts
@@ -13,6 +13,8 @@ const checkpoint = {
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
+ provenance: { role: 'inject', label: 'compact' },
+ form: null,
} as ConversationNode
const abandoned = {
diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts
index d368c2e1e5..01a6965027 100644
--- a/packages/context/session-reference/src/index.ts
+++ b/packages/context/session-reference/src/index.ts
@@ -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,
diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts
index 78df17058d..0693e0d212 100644
--- a/packages/context/session-reference/src/types.ts
+++ b/packages/context/session-reference/src/types.ts
@@ -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
diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts
index ff939219aa..4cc9e5de9c 100644
--- a/packages/context/time-context/src/index.ts
+++ b/packages/context/time-context/src/index.ts
@@ -174,13 +174,14 @@ export function apply(ctx: Context, config: Config): void {
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
+ const text = renderText(now, turn, step, previous, formatter, resolvedTimeZone)
return {
kind: 'enter',
messages: [
...decision.messages,
createUserMessage({
- content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
- source: { kind: 'plugin', plugin: name },
+ content: [{ type: 'text', text }],
+ source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
}),
],
}
diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts
index a4aa6bc3a2..33d63cf843 100644
--- a/packages/context/time-context/tests/time-context.e2e.ts
+++ b/packages/context/time-context/tests/time-context.e2e.ts
@@ -56,7 +56,14 @@ describe('time-context through a real headless cordis.yml', () => {
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
expect(contexts[index]!.surfaceOp).toBe('append')
- expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
+ // `snapshot` form: one named contribution whose text is exactly what the
+ // model read, so a consumer attributes it without re-splitting prose.
+ expect(contexts[index]!.data.source).toMatchObject({
+ kind: 'plugin',
+ plugin: 'time-context',
+ form: 'snapshot',
+ sections: [{ name: 'time-context' }],
+ })
}
const contextText = contexts.map(event => event.data.content
.filter(block => block.type === 'text')
diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts
index 1b85595bb9..94228bbc9f 100644
--- a/packages/context/time-context/tests/time-context.spec.ts
+++ b/packages/context/time-context/tests/time-context.spec.ts
@@ -161,7 +161,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')
})
diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts
index 130efb919b..ff9243416e 100644
--- a/packages/context/tmux-context/src/index.ts
+++ b/packages/context/tmux-context/src/index.ts
@@ -234,12 +234,13 @@ export function apply(ctx: Context, config: Config): void {
if (location === undefined) return decision
const state = renderState(location)
if (previous !== undefined && previous.state === state) return decision
+ const text = renderReading(location, turn)
return {
kind: 'enter',
messages: [
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 }] },
}),
...decision.messages,
],
diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts
index e7b501d462..d7e4a0a7fb 100644
--- a/packages/context/tmux-context/tests/tmux-context.spec.ts
+++ b/packages/context/tmux-context/tests/tmux-context.spec.ts
@@ -170,7 +170,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')
})
diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts
index be9e2aa806..efa9bdf771 100644
--- a/packages/context/workspace-context/src/index.ts
+++ b/packages/context/workspace-context/src/index.ts
@@ -147,6 +147,7 @@ export function apply(ctx: Context, config: Config): void {
content,
source: {
kind: 'workspace-instructions',
+ form: 'instructions',
...desiredBaseline ? { baseline: true } : {},
changes,
},
diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts
index 960ee48ccb..d0176eef8f 100644
--- a/packages/context/workspace-context/src/state.ts
+++ b/packages/context/workspace-context/src/state.ts
@@ -36,6 +36,8 @@ export const name = 'workspace-context'
/** Durable provenance and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
+ /** Every workspace context carries instructions read out of a file (the `instructions` context form). */
+ form: 'instructions'
/** Marks the complete startup/resume baseline rather than a later delta. */
baseline?: true
changes: WorkspaceInstructionChange[]
@@ -77,7 +79,7 @@ export interface ReconciledInstructionContext {
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage {
return createUserMessage({
content: [{ type: 'text', text }],
- source: { kind: 'workspace-instructions', changes },
+ source: { kind: 'workspace-instructions', form: 'instructions', changes },
})
}
diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts
index b55b11bbe1..ae5889b241 100644
--- a/packages/context/workspace-context/tests/workspace-context.spec.ts
+++ b/packages/context/workspace-context/tests/workspace-context.spec.ts
@@ -916,6 +916,7 @@ describe('workspace context request injection', () => {
role: 'user',
source: {
kind: 'workspace-instructions',
+ form: 'instructions',
baseline: true,
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
@@ -1113,6 +1114,7 @@ describe('workspace context request injection', () => {
content: [{ type: 'text', text: 'stale nested instructions' }],
source: {
kind: 'workspace-instructions',
+ form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
},
}), {
@@ -1146,6 +1148,7 @@ describe('workspace context request injection', () => {
content: [{ type: 'text', text: 'stale nested instructions' }],
source: {
kind: 'workspace-instructions',
+ form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
},
}), {
@@ -2218,7 +2221,7 @@ describe('dynamic nested workspace context injection', () => {
expect(result.isError).toBe(false)
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' })
const queuedSource = ((await syncedWorkspaceContext(ctx, agent))).source
- expect(queuedSource).toMatchObject({ kind: 'workspace-instructions' })
+ expect(queuedSource).toMatchObject({ kind: 'workspace-instructions', form: 'instructions' })
expect(queuedSource.kind === 'workspace-instructions' && queuedSource.changes.some(change =>
change.action === 'set'
&& change.scope === sk('pkg', 'AGENTS.md')
@@ -2561,6 +2564,7 @@ describe('dynamic nested workspace context injection', () => {
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
+ form: 'instructions',
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([
@@ -2670,7 +2674,7 @@ describe('dynamic nested workspace context injection', () => {
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
const authoritative = createUserMessage({
content: [{ type: 'text', text: 'nested rule' }],
- source: { kind: 'workspace-instructions', changes: [previous] },
+ source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] },
})
if (authority === 'visible') {
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
@@ -2696,6 +2700,7 @@ describe('dynamic nested workspace context injection', () => {
content: [{ type: 'text', text: 'pending baseline duplicate' }],
source: {
kind: 'workspace-instructions',
+ form: 'instructions',
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
})],
@@ -2739,7 +2744,7 @@ describe('dynamic nested workspace context injection', () => {
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
const authoritative = createUserMessage({
content: [{ type: 'text', text: 'repo rule' }],
- source: { kind: 'workspace-instructions', changes: [previous] },
+ source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] },
})
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
const resolved = resolveConfig({ dshHome: home, maxBytes: 65536, localInstructionFileCandidates: [] })
@@ -2863,6 +2868,7 @@ describe('dynamic nested workspace context injection', () => {
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
+ form: 'instructions',
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([
@@ -3265,6 +3271,7 @@ describe('dynamic nested workspace context injection', () => {
],
source: {
kind: 'workspace-instructions',
+ form: 'instructions',
changes: [
null,
{ action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
@@ -3418,6 +3425,7 @@ describe('dynamic nested workspace context injection', () => {
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' })
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
+ form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index fb1144ed8c..d41da6ac9d 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -1793,6 +1793,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ContentBlockType',
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
},
+ {
+ 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: 'ContextSnapshotSection',
+ declaration: 'export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n}',
+ },
{
name: 'ContinuableCreateRequest',
declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}',
@@ -2119,7 +2127,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'MessageSourceMap',
- declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}',
+ declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}',
},
{
name: 'ModelMessageSource',
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index 1931d6efb0..2b97dad3a4 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -27,7 +27,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
-import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
+import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { Context } from 'cordis'
import { RuntimeContextProjection } from './runtime-context.ts'
@@ -202,7 +202,8 @@ export class ReactLoopAgent implements Agent {
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
- const context = this.runtimeContext.project(renderContextSnapshot(assembly))
+ const sections = renderContextSections(assembly)
+ const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await agentEvents(this.loopCtx, this).waterfall(
'agent/pre-step', claimed, { ...position, signal },
() => Promise.resolve({
diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts
index 26c33a18c5..8cf4a41403 100644
--- a/packages/core/agent-loop/src/runtime-context.ts
+++ b/packages/core/agent-loop/src/runtime-context.ts
@@ -4,6 +4,7 @@
*/
import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
@@ -57,15 +58,19 @@ export class RuntimeContextProjection {
/**
* Create an uncommitted snapshot only when the retained value differs.
* @param current - fully rendered dynamic context.
+ * @param sections - named contributions that formed the current snapshot.
* @returns a candidate user message, or `undefined` when no update is needed.
*/
- project(current: string): UserMessage | undefined {
+ project(current: string, sections: readonly ContextSnapshotSection[]): UserMessage | undefined {
if (this.retained === undefined && current.length === 0) return
const snapshot = current.length === 0 ? CLEARED : current
if (this.retained?.text === snapshot) return
return createUserMessage({
content: [{ type: 'text', text: snapshot }],
- source: { kind: 'plugin', plugin: SOURCE },
+ // The cleared marker has no contributions left to attribute.
+ source: sections.length === 0
+ ? { kind: 'plugin', plugin: SOURCE }
+ : { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections },
})
}
}
diff --git a/packages/core/agent-loop/tests/runtime-context.spec.ts b/packages/core/agent-loop/tests/runtime-context.spec.ts
index ed524a9e4b..463515a61b 100644
--- a/packages/core/agent-loop/tests/runtime-context.spec.ts
+++ b/packages/core/agent-loop/tests/runtime-context.spec.ts
@@ -30,10 +30,16 @@ describe('RuntimeContextProjection', () => {
const projection = new RuntimeContextProjection(ctx, session)
expect(session.surface.nodes).toContain(retained.seq)
- expect(projection.project('retained')).toBeUndefined()
+ expect(projection.project('retained', [])).toBeUndefined()
+ expect(projection.project('next', [{ name: 'sandbox:policy', text: 'policy' }])?.source).toEqual({
+ kind: 'plugin',
+ plugin: SOURCE,
+ form: 'snapshot',
+ sections: [{ name: 'sandbox:policy', text: 'policy' }],
+ })
const other = ctx.sessions.create(SessionId('runtime-context-other'))
other.append('user/message', contextMessage('other'), { surfaceOp: 'append' })
- expect(projection.project('retained')).toBeUndefined()
+ expect(projection.project('retained', [])).toBeUndefined()
})
})
diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts
index cb191bcada..16fc1394fa 100644
--- a/packages/core/system-prompt/src/index.ts
+++ b/packages/core/system-prompt/src/index.ts
@@ -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 {
@@ -200,15 +200,39 @@ 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')
+ 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}`
}
-/** Interpolate one section or context and attribute diagnostics to its owner. */
+/**
+ * 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,
variables: Record,
diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts
index 99e8ed7c91..a524fe0630 100644
--- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts
+++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts
@@ -476,9 +476,7 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(loadedRequest).toContain('Use the freshly loaded body.')
const transcript = handle.agent.session.events.flatMap>((event) => {
- if (event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === 'dsh-tool-skill') {
+ if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') {
return [{
type: event.type,
source: event.data.source,
@@ -512,8 +510,14 @@ describe('dsh-agent-spine-demo bundle', () => {
},
{
"source": {
- "kind": "plugin",
- "plugin": "dsh-tool-skill",
+ "entries": [
+ {
+ "description": "Hot-added skill",
+ "name": "hot-skill",
+ },
+ ],
+ "form": "catalog",
+ "kind": "skill-catalog",
},
"text": "
A skill is a reusable set of task-specific instructions. The following skills are available in this session:
diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts
index 9ceed21bc9..d22ff26dc2 100644
--- a/packages/goal/tool-goal/src/index.ts
+++ b/packages/goal/tool-goal/src/index.ts
@@ -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'
@@ -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: boundContextSummary(`${args.action as string}: ${goal.objective}`),
+ },
}))
}
return Promise.resolve(goalValue(goal))
diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts
index 3b1e892ec5..c9e3ade8fe 100644
--- a/packages/goal/tool-goal/tests/tool-goal.spec.ts
+++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts
@@ -374,7 +374,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('')
diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts
index d58d4f0528..f07d59e417 100644
--- a/packages/guard/repeat-tool-guard/src/index.ts
+++ b/packages/guard/repeat-tool-guard/src/index.ts
@@ -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
diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
index a7c31a2a09..d6858f6d1a 100644
--- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
+++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
@@ -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')
diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts
index 3fa7606c6c..7db1f855a8 100644
--- a/packages/llm/llm/src/message.ts
+++ b/packages/llm/llm/src/message.ts
@@ -29,17 +29,99 @@ export interface ToolMessageSource {
callId: CallId
}
+/**
+ * What SHAPE of information a producer-supplied context carries, declared by
+ * the producer beside its provenance.
+ *
+ * `MessageSource.kind` answers *who produced this*; `form` answers *what kind
+ * of thing it is*, and the two axes are deliberately independent — several
+ * producers share one form (three snapshot producers today), and one producer
+ * may emit more than one form over a session.
+ *
+ * The vocabulary is SEMANTIC, never visual: a value states that the content is
+ * a file's instructions or a catalog of available items, and a consumer decides
+ * what that looks like. Colors, icons, ordering, and collapse defaults are the
+ * consumer's business and must not enter this union. It grows one value at a
+ * time as producers gain the structured fields their form needs; an absent or
+ * unknown value is the documented default, presented as opaque content.
+ */
+export type ContextForm =
+ /** Instructions read out of workspace files the model is expected to follow. */
+ | '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'
+
+/** 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.
*/
export interface MessageSourceMap {
user: { kind: 'user' }
- plugin: { kind: 'plugin'; plugin: string }
+ plugin: { kind: 'plugin'; plugin: string } & ContextFormed
model: ModelMessageSource
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]
diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts
index 835b4b9036..75b8ffd36b 100644
--- a/packages/plan/plan-mode/src/index.ts
+++ b/packages/plan/plan-mode/src/index.ts
@@ -464,7 +464,8 @@ export class PlanModeService extends Service {
: 'The user switched this session back to the default mode.'
return 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 },
})
}
}
diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml
index 575f220c0a..b57689d742 100644
--- a/packages/skill/tool-skill/README.i18n.yaml
+++ b/packages/skill/tool-skill/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
-README.md: 05e7f7f4d08c52c1bfa0b7f67a618231b29f3e43
-README.zh.md: 23ce58c867d33d433fa69212562a5ccd13ba9d0e
+README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626
+README.zh.md: c6b815bef59eb1f14be0892078694f129366d004
diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md
index 05e7f7f4d0..8e0bff5d1c 100644
--- a/packages/skill/tool-skill/README.md
+++ b/packages/skill/tool-skill/README.md
@@ -10,11 +10,11 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools
At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the pre-step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it adds an initial durable user-role `` to a downstream `enter` decision. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog.
-The digest covers the exact rendered text between the `` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, an entering pre-step receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
+Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing cannot decide whether a republish is needed and consumers never re-parse the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.
-`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.
+`catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle.
## Tool: `skill`
diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md
index 23ce58c867..c6b815bef5 100644
--- a/packages/skill/tool-skill/README.zh.md
+++ b/packages/skill/tool-skill/README.zh.md
@@ -10,11 +10,11 @@
每次符合条件的 `agent/pre-step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将 pre-step 中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会向下游 `enter` 决策添加初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
-该 digest 覆盖 `` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,进入步骤的 pre-step 会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
+每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。
-`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
+`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。
## 工具:`skill`
diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts
index b343fee8b0..634824d6c2 100644
--- a/packages/skill/tool-skill/src/index.ts
+++ b/packages/skill/tool-skill/src/index.ts
@@ -22,9 +22,37 @@ export const name = 'tool-skill'
export const inject = ['agents', 'tools', 'skills']
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
-const CATALOG_ENTRIES_START = '\n'
-const CATALOG_ENTRIES_END = ' '
-const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const
+/**
+ * Durable provenance for one published session skill catalog. The catalog is a
+ * `catalog`-form context, so it records the entries it published beside the
+ * model-facing prose: a consumer presenting the list must not re-parse the
+ * `` block, whose framing exists for the model.
+ */
+export interface SkillCatalogSource {
+ readonly kind: 'skill-catalog'
+ readonly form: 'catalog'
+ /** Marks a replacement catalog rather than this session's first publication. */
+ readonly update?: true
+ /** Exactly the entries this message published, in catalog order. */
+ readonly entries: readonly { readonly name: string; readonly description: string }[]
+}
+
+declare module '@deepseek-ai/dsh-llm' {
+ interface MessageSourceMap {
+ 'skill-catalog': SkillCatalogSource
+ }
+}
+
+/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */
+function catalogSourceEntries(
+ skills: SkillSummary[],
+ descriptionMaxLength: number,
+): SkillCatalogSource['entries'] {
+ return skills.map(skill => ({
+ name: skill.name,
+ description: catalogDescription(skill.description, descriptionMaxLength),
+ }))
+}
/** Model-facing skill catalog configuration. */
export interface Config {
@@ -150,28 +178,29 @@ export function apply(ctx: Context, config: Config = {}): void {
signal.throwIfAborted()
if (!snapshot.complete) return decision
const skills = snapshot.skills.filter(isModelInvocable)
- const digest = catalogDigest(skills, catalogDescriptionMaxLength)
+ const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
+ const digest = digestCatalogEntries(entries)
const history = catalogHistory(agent)
const existing = catalogMessage(decision.messages)
if (history.visibleDigest === digest) {
return existing === undefined
? decision
- : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) }
+ : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
}
- if (existing !== undefined && catalogContentDigest(existing.content) === digest) return decision
+ if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision
if (!history.published && skills.length === 0) {
return existing === undefined
? decision
- : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) }
+ : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
}
const catalog = history.published
- ? renderCatalogUpdate(skills, catalogDescriptionMaxLength)
- : renderCatalogMessage(skills, catalogDescriptionMaxLength)
+ ? renderCatalogUpdate(entries)
+ : renderCatalogMessage(entries)
return {
kind: 'enter',
messages: existing === undefined
? [...decision.messages, catalog]
- : decision.messages.map(message => message.id === existing.id ? catalog : message),
+ : decision.messages.map(message => message.id === existing.message.id ? catalog : message),
}
})
}
@@ -222,8 +251,7 @@ function renderResourceHint(skill: Pick',
- ...entries,
+ ...renderCatalogEntries(entries),
' ',
'',
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
' ',
].join('\n'),
}],
- source: PLUGIN_SOURCE,
+ source: {
+ kind: 'skill-catalog',
+ form: 'catalog',
+ entries,
+ },
})
}
-function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
- const entries = renderCatalogEntries(skills, descriptionMaxLength)
- const availability = skills.length === 0
+function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage {
+ const availability = entries.length === 0
? [
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
]
@@ -260,31 +291,70 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
'',
'',
- ...entries,
+ ...renderCatalogEntries(entries),
' ',
'',
...availability,
' ',
].join('\n'),
}],
- source: PLUGIN_SOURCE,
+ source: {
+ kind: 'skill-catalog',
+ form: 'catalog',
+ update: true,
+ entries,
+ },
})
}
-function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] {
- return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
+/**
+ * Model-facing catalog lines, projected from the same entries the source records.
+ * The pseudo-XML escaping belongs to this frame, not to the published fact, so it
+ * is applied here and never stored. Names are `isSkillName`-validated and carry
+ * no escapable character.
+ */
+function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
+ return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`)
}
-function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string {
- return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n'))
-}
-
-function digestCatalogEntries(entries: string): string {
+/**
+ * Catalog identity over the durable entry list rather than the rendered prose.
+ * The entries are what changes; the surrounding `` framing is
+ * written for the model and must not decide whether a republish is needed.
+ */
+function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {
+ // JSON per entry rather than a separator character: every separator is itself
+ // a legal description character, so only quoting makes the boundary exact.
+ const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')
return createHash('sha256')
- .update(entries)
+ .update(canonical)
.digest('hex')
}
+/**
+ * Entries of one durable catalog message, or undefined when the record is not a
+ * usable catalog.
+ *
+ * `agent.session.events` may be a resumed, forked, or externally written seed,
+ * and seed validation only guarantees a source object with a non-empty `kind`;
+ * no per-kind field is checked there. An unreadable record is therefore treated
+ * as "not this plugin's catalog" — the posture the replaced content digest had —
+ * rather than throwing inside the step listener, which would fail every
+ * subsequent turn of that session.
+ */
+function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined {
+ const entries = (source as { entries?: unknown }).entries
+ if (!Array.isArray(entries)) return undefined
+ const readable: { name: string; description: string }[] = []
+ for (const entry of entries as readonly unknown[]) {
+ if (typeof entry !== 'object' || entry === null) return undefined
+ const { name, description } = entry as { name?: unknown; description?: unknown }
+ if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined
+ readable.push({ name, description })
+ }
+ return readable
+}
+
function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
const visible = new Set(agent.session.surface.nodes)
const events = agent.session.events
@@ -293,43 +363,31 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
// The loop bounds prove the read-only event view contains this index.
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
- if (event.type !== 'user/message'
- || event.data.source.kind !== 'plugin'
- || event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue
- const digest = catalogContentDigest(event.data.content)
- if (digest === undefined) continue
+ if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
+ const entries = readCatalogEntries(event.data.source)
+ if (entries === undefined) continue
+ const digest = digestCatalogEntries(entries)
published = true
if (visible.has(event.seq)) return { visibleDigest: digest, published }
}
return { published }
}
-function catalogMessage(messages: readonly UserMessage[]): UserMessage | undefined {
- return messages.find(message =>
- message.source.kind === 'plugin'
- && message.source.plugin === PLUGIN_SOURCE.plugin
- && catalogContentDigest(message.content) !== undefined)
-}
-
-function catalogContentDigest(content: UserMessage['content']): string | undefined {
- if (content.length !== 1 || content[0]?.type !== 'text') return undefined
- const text = content[0].text
- const start = text.indexOf(CATALOG_ENTRIES_START)
- if (start === -1) return undefined
- const entriesStart = start + CATALOG_ENTRIES_START.length
- const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart)
- if (end === -1) return undefined
- const renderedEntries = text.slice(entriesStart, end)
- const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries
- return digestCatalogEntries(entries)
+function catalogMessage(
+ messages: readonly UserMessage[],
+): { message: UserMessage; entries: SkillCatalogSource['entries'] } | undefined {
+ for (const message of messages) {
+ if (message.source.kind !== 'skill-catalog') continue
+ const entries = readCatalogEntries(message.source)
+ if (entries !== undefined) return { message, entries }
+ }
+ return undefined
}
+/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */
function catalogDescription(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
- const truncated = normalized.length <= maxLength
- ? normalized
- : `${normalized.slice(0, maxLength - 3)}...`
- return escapeText(truncated)
+ return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`
}
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts
index c3562564a0..5d14b7c523 100644
--- a/packages/skill/tool-skill/tests/tool-skill.spec.ts
+++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts
@@ -113,8 +113,15 @@ async function proposeStep(
function catalogMessages(session: Session): Extract[] {
return session.events.filter((event): event is Extract => event.type === 'user/message'
- && event.data.source.kind === 'plugin'
- && event.data.source.plugin === 'dsh-tool-skill')
+ && event.data.source.kind === 'skill-catalog')
+}
+
+function readableCatalog(event: Extract): boolean {
+ const entries = (event.data.source as { entries?: unknown }).entries
+ return Array.isArray(entries)
+ && entries.every(entry => typeof entry === 'object' && entry !== null
+ && typeof (entry as { name?: unknown }).name === 'string'
+ && typeof (entry as { description?: unknown }).description === 'string')
}
function catalogContent(entries: string[]): Message['content'] {
@@ -261,7 +268,15 @@ describe('dsh-tool-skill', () => {
{
id: expect.any(String) as unknown,
role: 'user',
- source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
+ source: {
+ kind: 'skill-catalog',
+ form: 'catalog',
+ entries: [
+ { name: 'a-skill', description: 'Use {{placeholder}} & carefully.' },
+ { name: 'model-only-skill', description: 'Model-only skill.' },
+ { name: 'z-skill', description: 'Long description Long description Long descript...' },
+ ],
+ },
content: [{
type: 'text',
text: [
@@ -394,14 +409,22 @@ describe('dsh-tool-skill', () => {
const home = await tempDir('tool-proposed-empty-catalog')
const ctx = await setup(home)
const session = Session.create(SessionId('proposed-empty-catalog'))
+ const malformed = createUserMessage({
+ content: [{ type: 'text', text: 'preserve unreadable claimed context' }],
+ source: { kind: 'skill-catalog', form: 'catalog' } as never,
+ })
const stale = createUserMessage({
content: catalogContent(['- `stale-skill`: Stale skill']),
- source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
+ source: {
+ kind: 'skill-catalog',
+ form: 'catalog',
+ entries: [{ name: 'stale-skill', description: 'Stale skill' }],
+ },
})
- const decision = await proposeStep(ctx, sessionAgent(session), [stale])
+ const decision = await proposeStep(ctx, sessionAgent(session), [malformed, stale])
- expect(decision).toEqual({ kind: 'enter', messages: [] })
+ expect(decision).toEqual({ kind: 'enter', messages: [malformed] })
})
it('keeps a proposed catalog that already matches the current snapshot', async () => {
@@ -416,7 +439,11 @@ describe('dsh-tool-skill', () => {
const session = Session.create(SessionId('matching-proposal'))
const proposed = createUserMessage({
content: catalogContent(['- `first-skill`: First skill']),
- source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
+ source: {
+ kind: 'skill-catalog',
+ form: 'catalog',
+ entries: [{ name: 'first-skill', description: 'First skill' }],
+ },
})
const decision = await proposeStep(ctx, sessionAgent(session), [proposed])
@@ -468,7 +495,12 @@ describe('dsh-tool-skill', () => {
expect(catalogMessages(session)).toHaveLength(3)
})
- it('resumes from the latest valid visible catalog content', async () => {
+ it('resumes from the durable entries of the latest visible catalog', async () => {
+ // Catalog identity moved onto `source.entries` when the catalog became a
+ // `catalog`-form context: the model-facing prose no longer decides whether
+ // a republish is needed, so a seeded message is recognized by its source
+ // alone and malformed prose can no longer hide (or fake) a published
+ // catalog. A foreign-sourced message is not this plugin's catalog at all.
const home = await tempDir('tool-catalog-resume')
const ctx = await setup(home)
ctx.skills.register({
@@ -481,30 +513,78 @@ describe('dsh-tool-skill', () => {
const agent = sessionAgent(session)
openMessageTurn(session)
session.append('user/message', createUserMessage({
- content: catalogContent(['- `old-skill`: Old skill']),
- source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
+ content: [{ type: 'text', text: 'prose a reader cannot rely on' }],
+ source: {
+ kind: 'skill-catalog',
+ form: 'catalog',
+ entries: [{ name: 'old-skill', description: 'Old skill' }],
+ },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'missing catalog markers' }],
- source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
- }), { surfaceOp: 'append' })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: '\nmissing closing marker' }],
- source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
- }), { surfaceOp: 'append' })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
- source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
- }), { surfaceOp: 'append' })
- session.append('user/message', createUserMessage({
- content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
+ content: catalogContent(['- `resumed-skill`: Resumed skill']),
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
await fireStep(ctx, agent, 1, 1)
- expect(catalogMessages(session)).toHaveLength(6)
- expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
+ // The seeded entries differ from the live snapshot, so one replacement
+ // lands; the foreign-sourced lookalike neither counts as published nor
+ // suppresses it.
+ expect(catalogMessages(session)).toHaveLength(2)
+ const latest = catalogMessages(session).at(-1)
+ expect(latest?.data.source).toMatchObject({
+ kind: 'skill-catalog',
+ form: 'catalog',
+ update: true,
+ entries: [{ name: 'resumed-skill', description: 'Resumed skill' }],
+ })
+ expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill')
+
+ // A second step over unchanged entries republishes nothing.
+ await fireStep(ctx, agent, 1, 2)
+ expect(catalogMessages(session)).toHaveLength(2)
+ })
+
+ it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => {
+ // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork,
+ // and seed validation only guarantees a source object with a non-empty
+ // `kind`. A catalog whose entries are missing or wrongly shaped must be
+ // skipped like any foreign record; throwing here would fail every later
+ // step of that session at the latest possible point.
+ const home = await tempDir('tool-catalog-malformed')
+ const ctx = await setup(home)
+ ctx.skills.register({
+ name: 'live-skill',
+ description: 'Live skill',
+ source: 'runtime',
+ content: 'Live body.',
+ })
+ const session = Session.create(SessionId('catalog-malformed'))
+ const agent = sessionAgent(session)
+ openMessageTurn(session)
+ for (const source of [
+ { kind: 'skill-catalog', form: 'catalog' },
+ { kind: 'skill-catalog', form: 'catalog', entries: null },
+ { kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' },
+ { kind: 'skill-catalog', form: 'catalog', entries: [null] },
+ { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] },
+ { kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] },
+ ]) {
+ session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'unreadable catalog' }],
+ source: source as never,
+ }), { surfaceOp: 'append' })
+ }
+
+ await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined()
+
+ // None of the six counted as published, so the live catalog lands as a
+ // first publication rather than a replacement.
+ const published = catalogMessages(session).filter(event => readableCatalog(event))
+ expect(published).toHaveLength(1)
+ expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' })
+ expect(published[0]?.data.source).not.toHaveProperty('update')
+ expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill')
})
it('re-establishes the current catalog after compaction hides its durable message', async () => {
diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts
index 3644180056..4b038871a1 100644
--- a/packages/subagent/subagent/src/continuation.ts
+++ b/packages/subagent/subagent/src/continuation.ts
@@ -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,
},
})
diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts
index 5406a4dcc4..95cf4e4183 100644
--- a/packages/subagent/tool-subagent-control/src/index.ts
+++ b/packages/subagent/tool-subagent-control/src/index.ts
@@ -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,
},
)
diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
index bc8d2aa5fd..302e053abe 100644
--- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
+++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
@@ -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,
})
})
diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts
index 5a907de989..5372fffdb2 100644
--- a/packages/tasks/tool-tasks/src/index.ts
+++ b/packages/tasks/tool-tasks/src/index.ts
@@ -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,6 +114,15 @@ function fitWithSuffix(
return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`
}
+/**
+ * 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 like every notice summary.
+ */
+function completionSummary(snapshot: TaskSnapshot): string {
+ return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`)
+}
+
function fitCompletionNotice(snapshot: TaskSnapshot): string {
const prefix = `background task ${snapshot.id}`
const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}`
@@ -228,7 +237,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),
+ },
}))
})
diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
index c04bca5295..0f1bce7ecf 100644
--- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
+++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
@@ -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)}…`,
+ },
},
)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0b993b1d73..34c8d60cf9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1703,6 +1703,9 @@ importers:
mdast-util-gfm:
specifier: ^3.1.0
version: 3.1.0
+ micromark-core-commonmark:
+ specifier: ^2.0.3
+ version: 2.0.3
micromark-extension-gfm:
specifier: ^3.0.0
version: 3.0.0
@@ -1715,6 +1718,9 @@ importers:
micromark-util-character:
specifier: ^2.1.1
version: 2.1.1
+ micromark-util-classify-character:
+ specifier: ^2.0.1
+ version: 2.0.1
micromark-util-symbol:
specifier: ^2.0.1
version: 2.0.1
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 5d85466347..73bb8844d0 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -26,6 +26,21 @@
"symbol": "MessageSourceMap",
"source": "packages/llm/llm/src/message.ts"
},
+ {
+ "doc": "docs/core-data-structures/core.md",
+ "symbol": "ContextForm",
+ "source": "packages/llm/llm/src/message.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/core.md",
+ "symbol": "ContextSnapshotSection",
+ "source": "packages/llm/llm/src/message.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/core.md",
+ "symbol": "ContextFormed",
+ "source": "packages/llm/llm/src/message.ts"
+ },
{
"doc": "docs/core-data-structures/core.md",
"symbol": "FinishReasonMap",
diff --git a/tsconfig.host.json b/tsconfig.host.json
index c13d480a46..4fcf71b680 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -38,6 +38,8 @@
"apps/web/tests/message-actions.e2e.ts",
"apps/web/tests/markdown-images.e2e.ts",
"apps/web/tests/math-rendering.e2e.ts",
+ "apps/web/tests/markdown-cjk-strong.e2e.ts",
+ "apps/web/tests/markdown-inline-code-links.e2e.ts",
"apps/web/tests/queue-actions.e2e.ts",
"apps/web/tests/skill-invocation-policy.e2e.ts",
"apps/web/tests/permission-policy-context.e2e.ts",