diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 95044920c7..56eaa899df 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -567,7 +567,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/domain.ts:142`](../../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:140`](../../packages/goal/goal/src/domain.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 68a47af354..1ee462bcd4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2163,7 +2163,7 @@ async assemble(context: AssembleContext = {}): Promise Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -Source: [`packages/core/system-prompt/src/index.ts:310`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:322`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` (abstract seam) diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml index e3bb063550..c9a61ca397 100644 --- a/docs/core-data-structures/goal.i18n.yaml +++ b/docs/core-data-structures/goal.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 docs/core-data-structures/goal.md -goal.md: 8627b927fcff1c32faac0e76356f576bdbb29ba8 -goal.zh.md: f1c3a2e4d65d1efe849aab565e8beb79e0284aae +goal.md: 5b9b751d734a98d5fc7b0333eeb52febadbca22a +goal.zh.md: 0ce1a2166abc234225309bfb9a513bdf9ed30d4a diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index 8627b927fc..5b9b751d73 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -101,22 +101,20 @@ Goal state changes use round `0`. A continuation consumer attributes each admitt ```ts type-equiv /** Message attribution for durable goal state and continuation rounds. */ -interface GoalMessageSource { +type GoalMessageSource = { readonly kind: 'goal' - /** - * Round-zero state changes are `notice`-form contexts; a continuation round - * carries the objective forward as ordinary context and declares no form. - */ - readonly form?: 'notice' - /** Present with `form`: one-line account of the mutation. */ - readonly summary?: string readonly goalId: GoalId readonly revision: number /** Zero for state changes; positive for admitted continuation rounds. */ readonly round: number /** Complete durable mutation carried only by round-zero state-change messages. */ readonly change?: GoalChangeMeta -} + /** + * Round-zero state changes are `notice`-form contexts; a continuation round + * carries the objective forward as ordinary context and declares no form. + * Discriminated so the account cannot be omitted when the form is declared. + */ +} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never }) ``` ## Requests and notifications diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md index f1c3a2e4d6..0ce1a2166a 100644 --- a/docs/core-data-structures/goal.zh.md +++ b/docs/core-data-structures/goal.zh.md @@ -101,22 +101,20 @@ interface GoalClearChangeMeta { ```ts type-equiv /** Message attribution for durable goal state and continuation rounds. */ -interface GoalMessageSource { +type GoalMessageSource = { readonly kind: 'goal' - /** - * Round-zero state changes are `notice`-form contexts; a continuation round - * carries the objective forward as ordinary context and declares no form. - */ - readonly form?: 'notice' - /** Present with `form`: one-line account of the mutation. */ - readonly summary?: string readonly goalId: GoalId readonly revision: number /** Zero for state changes; positive for admitted continuation rounds. */ readonly round: number /** Complete durable mutation carried only by round-zero state-change messages. */ readonly change?: GoalChangeMeta -} + /** + * Round-zero state changes are `notice`-form contexts; a continuation round + * carries the objective forward as ordinary context and declares no form. + * Discriminated so the account cannot be omitted when the form is declared. + */ +} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never }) ``` ## 请求与通知 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 10277dc1cc..a0674e101b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,7 +31,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:142`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:140`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx index 4678da9272..6af65bcdb9 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx @@ -374,6 +374,13 @@ function snapshotSections(source: unknown): SnapshotSection[] | null { * The sections are the same bytes the model read, split at the boundaries the * producer assembled them on, so a reader sees which subsystem contributed * which state instead of one undifferentiated wall. + * + * One sentence of the model-facing text is NOT in any section: the producer's + * framing line declaring that this snapshot supersedes earlier ones. Unlike the + * `` wrapper an instruction context carries — which wraps + * content and cannot be separated from it — that line states the form's own + * semantics, so the body states them as a caption instead of reprinting the + * joined prose beside the sections it was split from. * @param props - Durable content, its source, and the locale seat. * @returns The snapshot context body, or the opaque body when unreadable. */ @@ -383,16 +390,22 @@ export function SnapshotBody({ content, source, t }: { t: Translate }): ReactNode { const sections = snapshotSections(source) + /* v8 ignore next -- contextBody reads the sections before choosing this body. */ if (sections === null) return return ( -
- {sections.map((section, index) => ( -
-
{section.name}
-
{boundedText(section.text, t)}
-
- ))} -
+ <> +

+ {t('message.context.snapshot.supersedes')} +

+
+ {sections.map((section, index) => ( +
+
{section.name}
+
{boundedText(section.text, t)}
+
+ ))} +
+ ) } @@ -425,10 +438,9 @@ export function RelayBody({ content, source, t }: { source: unknown t: Translate }): ReactNode { - const sender = asRecord(source)?.['senderSessionId'] - if (typeof sender !== 'string' || sender === '') { - return - } + const sender = relaySender(source) + /* v8 ignore next -- contextBody resolves the sender before choosing this body. */ + if (sender === null) return return ( <>

@@ -439,11 +451,17 @@ export function RelayBody({ content, source, t }: { ) } +/** The sending agent's session id, or null when the record does not name one. */ +function relaySender(source: unknown): string | null { + const sender = asRecord(source)?.['senderSessionId'] + return typeof sender === 'string' && sender !== '' ? sender : null +} + /** One recalled session, as the durable source records it. */ interface RecalledSession { label: string - retained: number | null - omitted: number | null + retained: number + omitted: number truncated: boolean } @@ -457,15 +475,16 @@ function recalledSessions(source: unknown): RecalledSession[] | null { const reference = asRecord(item) if (reference === null) return null const label = reference['label'] - if (typeof label !== 'string' || label === '') return null const retained = reference['retainedMessages'] const omitted = reference['omittedMessages'] - sessions.push({ - label, - retained: typeof retained === 'number' ? retained : null, - omitted: typeof omitted === 'number' ? omitted : null, - truncated: reference['truncated'] === true, - }) + const truncated = reference['truncated'] + // Completeness is the fact this card exists to report, so a reference that + // cannot state it is not a readable recall — showing the label alone would + // present a confident card over unknown loss. + if (typeof label !== 'string' || label === '' + || typeof retained !== 'number' || typeof omitted !== 'number' + || typeof truncated !== 'boolean') return null + sessions.push({ label, retained, omitted, truncated }) } return sessions.length === 0 ? null : sessions } @@ -493,14 +512,12 @@ export function RecallBody({ content, source, t }: { {sessions.map((session, index) => (

  • {session.label} - {session.retained !== null && session.omitted !== null && ( - - {t('message.context.recall.counts', { - retained: session.retained, - omitted: session.omitted, - })} - - )} + + {t('message.context.recall.counts', { + retained: session.retained, + omitted: session.omitted, + })} + {session.truncated && ( {t('message.context.recall.truncated')} )} @@ -555,7 +572,9 @@ export function contextBody( : { rendered: 'notice', summary, body: } } case 'relay': - return { rendered: 'relay', summary: null, body: } + return relaySender(props.source) === null + ? opaque + : { rendered: 'relay', summary: null, body: } case 'recall': return recalledSessions(props.source) === null ? opaque diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 6fb2daa218..02df3f687c 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -61,6 +61,7 @@ export const zh = { 'message.context.instructions.removed': '已移除', 'message.context.catalog.replaced': '替换目录', 'message.context.catalog.more': '…还有 {count} 条', + 'message.context.snapshot.supersedes': '取代先前的快照', 'message.context.relay.from': '来自会话 {session}', 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', @@ -185,6 +186,7 @@ export const en = { 'message.context.instructions.removed': 'removed', 'message.context.catalog.replaced': 'Replacement catalog', 'message.context.catalog.more': '… {count} more', + 'message.context.snapshot.supersedes': 'Supersedes earlier snapshots', 'message.context.relay.from': 'From session {session}', 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 7ed274052b..3bc24de288 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -617,6 +617,45 @@ describe('MessageItem arms', () => { expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() }) + it('each form falls back to the opaque body when its required facts are unreadable', () => { + // The fallback chain is the load-bearing wall: every dedicated form must + // reach it, and the row marker must not claim a form that did not render. + const cases = [ + { form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' }, + { form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' }, + { form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' }, + ] as const + for (const { form, source, label } of cases) { + cleanup() + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) })) + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`) + expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) + .toBeNull() + } + }) + + it('a snapshot states the supersession its framing line carries', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) + expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent) + .toBe('取代先前的快照') + }) + it('a relay names the agent that sent it above what it said', () => { const view = render( section.text).join('\n\n') + return joinContextSections(renderContextSections(assembly)) +} + +/** + * The model-facing snapshot text for an already-rendered section list. + * + * A caller that also needs the sections renders them once and joins here, so a + * request does not interpolate every context twice. + * @param sections - sections from {@link renderContextSections}. + * @returns the current full snapshot, or `''` when no context is active. + */ +export function joinContextSections(sections: readonly ContextSnapshotSection[]): string { + const body = sections.map(section => section.text).join('\n\n') if (body.length === 0) return '' return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}` } diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 4eabcd2870..df828367be 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -59,22 +59,20 @@ export interface GoalClearChangeMeta { export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta /** Message attribution for durable goal state and continuation rounds. */ -export interface GoalMessageSource { +export type GoalMessageSource = { readonly kind: 'goal' - /** - * Round-zero state changes are `notice`-form contexts; a continuation round - * carries the objective forward as ordinary context and declares no form. - */ - readonly form?: 'notice' - /** Present with `form`: one-line account of the mutation. */ - readonly summary?: string readonly goalId: GoalId readonly revision: number /** Zero for state changes; positive for admitted continuation rounds. */ readonly round: number /** Complete durable mutation carried only by round-zero state-change messages. */ readonly change?: GoalChangeMeta -} + /** + * Round-zero state changes are `notice`-form contexts; a continuation round + * carries the objective forward as ordinary context and declares no form. + * Discriminated so the account cannot be omitted when the form is declared. + */ +} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never }) declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts index 047c55686a..fa2c608acf 100644 --- a/packages/goal/goal/src/render.ts +++ b/packages/goal/goal/src/render.ts @@ -1,5 +1,6 @@ /** Model-visible rendering for durable goal mutations. */ +import { boundContextSummary } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { GoalChangeMeta } from './domain.ts' @@ -9,10 +10,11 @@ import type { GoalChangeMeta } from './domain.ts' * @returns the operation and, for a surviving goal, its objective. */ export function goalChangeSummary(change: GoalChangeMeta): string { - // The row header already names the producer, so the account does not repeat it. - return change.operation === 'clear' + // The row header already names the producer, so the account does not repeat + // it. The objective is unbounded caller text, so the account is bounded. + return boundContextSummary(change.operation === 'clear' ? change.operation - : `${change.operation}: ${change.goal.objective}` + : `${change.operation}: ${change.goal.objective}`) } /** diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index ee5f0ca5f0..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' @@ -319,7 +319,7 @@ export function apply(ctx: Context, config: Config): void { kind: 'plugin', plugin: 'tool-goal', form: 'notice', - summary: `${args.action as string}: ${goal.objective}`, + summary: boundContextSummary(`${args.action as string}: ${goal.objective}`), }, })) } diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 0d4fff1248..7db1f855a8 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -104,6 +104,24 @@ export interface MessageSourceMap { tool: ToolMessageSource } +/** + * Bound for a `notice` summary. The account rides a collapsed transcript row + * and is committed to the durable log, while its inputs — task labels, goal + * objectives, tool arguments — are caller text with no length of their own. + */ +export const CONTEXT_SUMMARY_MAX_CHARS = 120 + +/** + * Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}. + * @param summary - the producer's one-line account, of any length. + * @returns the account, ellipsized when it exceeds the bound. + */ +export function boundContextSummary(summary: string): string { + return summary.length <= CONTEXT_SUMMARY_MAX_CHARS + ? summary + : `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…` +} + /** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */ export type MessageSource = MessageSourceMap[keyof MessageSourceMap] diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 988c2e5953..313720c831 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,24 +114,13 @@ function fitWithSuffix( return `${retainTail(content, maxBytes - fixedBytes)}${fixed}` } -/** - * Bound for the durable one-line account. A notice summary rides a collapsed - * transcript row, and both the task label and its status detail are caller - * text with no length of their own, so the summary caps itself rather than - * committing unbounded prose to the log. - */ -const SUMMARY_MAX_CHARS = 120 - /** * One-line account of a settled task for the `notice` form's collapsed row. * @param snapshot - the settled task. - * @returns its kind, label, and status, bounded to {@link SUMMARY_MAX_CHARS}. + * @returns its kind, label, and status, bounded like every notice summary. */ function completionSummary(snapshot: TaskSnapshot): string { - const summary = `${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}` - return summary.length <= SUMMARY_MAX_CHARS - ? summary - : `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…` + return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`) } function fitCompletionNotice(snapshot: TaskSnapshot): string {