fix(web): address the review round on the remaining context forms
- `relay` resolves its sender in `contextBody` like every other form. It was
the one shape whose marker could claim a form the body did not render: an
unreadable sender fell back inside the body while the row still said relay,
contradicting the contract this PR's own note states.
- `recall` requires the retained, omitted, and truncated fields. Completeness
is what the card exists to report, so a reference that cannot state it is
not a readable recall — showing the label alone presents a confident card
over unknown loss.
- The snapshot body states the supersession its producer framing line carries.
That line is the one part of the model-facing text no section contains, and
unlike an instruction context's `<system-reminder>` it states the form's own
semantics rather than wrapping content.
- `GoalMessageSource` is a discriminated pair, so `{ form: 'notice' }` without
its account no longer compiles. The guarantee this PR claims now holds at
that seam too, not only through `ContextFormed` on plugin sources.
- Goal and tool-goal summaries are bounded by a shared `boundContextSummary`,
which tool-tasks now uses as well. A goal objective is unbounded caller text
in exactly the way a task label is.
- The runtime snapshot interpolates once per request: agent-loop renders the
sections and joins them through `joinContextSections`.
- Every form's fallback branch is pinned, not only the notice one.
This commit is contained in:
@@ -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)
|
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/*`
|
## `llm/*`
|
||||||
|
|
||||||
|
|||||||
@@ -2163,7 +2163,7 @@ async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
|||||||
|
|
||||||
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)
|
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)
|
## `ctx.tasks` — `TaskService` (abstract seam)
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/goal.md
|
# pnpm run verify-translation-pairing --write docs/core-data-structures/goal.md
|
||||||
goal.md: 8627b927fcff1c32faac0e76356f576bdbb29ba8
|
goal.md: 5b9b751d734a98d5fc7b0333eeb52febadbca22a
|
||||||
goal.zh.md: f1c3a2e4d65d1efe849aab565e8beb79e0284aae
|
goal.zh.md: 0ce1a2166abc234225309bfb9a513bdf9ed30d4a
|
||||||
|
|||||||
@@ -101,22 +101,20 @@ Goal state changes use round `0`. A continuation consumer attributes each admitt
|
|||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** Message attribution for durable goal state and continuation rounds. */
|
/** Message attribution for durable goal state and continuation rounds. */
|
||||||
interface GoalMessageSource {
|
type GoalMessageSource = {
|
||||||
readonly kind: 'goal'
|
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 goalId: GoalId
|
||||||
readonly revision: number
|
readonly revision: number
|
||||||
/** Zero for state changes; positive for admitted continuation rounds. */
|
/** Zero for state changes; positive for admitted continuation rounds. */
|
||||||
readonly round: number
|
readonly round: number
|
||||||
/** Complete durable mutation carried only by round-zero state-change messages. */
|
/** Complete durable mutation carried only by round-zero state-change messages. */
|
||||||
readonly change?: GoalChangeMeta
|
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
|
## Requests and notifications
|
||||||
|
|||||||
@@ -101,22 +101,20 @@ interface GoalClearChangeMeta {
|
|||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** Message attribution for durable goal state and continuation rounds. */
|
/** Message attribution for durable goal state and continuation rounds. */
|
||||||
interface GoalMessageSource {
|
type GoalMessageSource = {
|
||||||
readonly kind: 'goal'
|
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 goalId: GoalId
|
||||||
readonly revision: number
|
readonly revision: number
|
||||||
/** Zero for state changes; positive for admitted continuation rounds. */
|
/** Zero for state changes; positive for admitted continuation rounds. */
|
||||||
readonly round: number
|
readonly round: number
|
||||||
/** Complete durable mutation carried only by round-zero state-change messages. */
|
/** Complete durable mutation carried only by round-zero state-change messages. */
|
||||||
readonly change?: GoalChangeMeta
|
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 })
|
||||||
```
|
```
|
||||||
|
|
||||||
## 请求与通知
|
## 请求与通知
|
||||||
|
|||||||
@@ -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/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/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) |
|
| `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/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) |
|
| `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) |
|
| `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) |
|
||||||
|
|||||||
@@ -374,6 +374,13 @@ function snapshotSections(source: unknown): SnapshotSection[] | null {
|
|||||||
* The sections are the same bytes the model read, split at the boundaries the
|
* 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
|
* producer assembled them on, so a reader sees which subsystem contributed
|
||||||
* which state instead of one undifferentiated wall.
|
* which state instead of one undifferentiated wall.
|
||||||
|
*
|
||||||
|
* One sentence of the model-facing text is NOT in any section: the producer's
|
||||||
|
* framing line declaring that this snapshot supersedes earlier ones. Unlike the
|
||||||
|
* `<system-reminder>` wrapper an instruction context carries — which wraps
|
||||||
|
* content and cannot be separated from it — that line states the form's own
|
||||||
|
* semantics, so the body states them as a caption instead of reprinting the
|
||||||
|
* joined prose beside the sections it was split from.
|
||||||
* @param props - Durable content, its source, and the locale seat.
|
* @param props - Durable content, its source, and the locale seat.
|
||||||
* @returns The snapshot context body, or the opaque body when unreadable.
|
* @returns The snapshot context body, or the opaque body when unreadable.
|
||||||
*/
|
*/
|
||||||
@@ -383,16 +390,22 @@ export function SnapshotBody({ content, source, t }: {
|
|||||||
t: Translate
|
t: Translate
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const sections = snapshotSections(source)
|
const sections = snapshotSections(source)
|
||||||
|
/* v8 ignore next -- contextBody reads the sections before choosing this body. */
|
||||||
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
|
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||||
return (
|
return (
|
||||||
<dl className={css.sections} data-context-sections>
|
<>
|
||||||
{sections.map((section, index) => (
|
<p className={css.catalogNotice} data-context-snapshot-supersedes>
|
||||||
<div key={index} className={css.section}>
|
{t('message.context.snapshot.supersedes')}
|
||||||
<dt className={css.sectionName}>{section.name}</dt>
|
</p>
|
||||||
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
|
<dl className={css.sections} data-context-sections>
|
||||||
</div>
|
{sections.map((section, index) => (
|
||||||
))}
|
<div key={index} className={css.section}>
|
||||||
</dl>
|
<dt className={css.sectionName}>{section.name}</dt>
|
||||||
|
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,10 +438,9 @@ export function RelayBody({ content, source, t }: {
|
|||||||
source: unknown
|
source: unknown
|
||||||
t: Translate
|
t: Translate
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const sender = asRecord(source)?.['senderSessionId']
|
const sender = relaySender(source)
|
||||||
if (typeof sender !== 'string' || sender === '') {
|
/* v8 ignore next -- contextBody resolves the sender before choosing this body. */
|
||||||
return <OpaqueBody content={content} source={source} t={t} />
|
if (sender === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className={css.relaySender} data-context-relay-sender>
|
<p className={css.relaySender} data-context-relay-sender>
|
||||||
@@ -439,11 +451,17 @@ export function RelayBody({ content, source, t }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The sending agent's session id, or null when the record does not name one. */
|
||||||
|
function relaySender(source: unknown): string | null {
|
||||||
|
const sender = asRecord(source)?.['senderSessionId']
|
||||||
|
return typeof sender === 'string' && sender !== '' ? sender : null
|
||||||
|
}
|
||||||
|
|
||||||
/** One recalled session, as the durable source records it. */
|
/** One recalled session, as the durable source records it. */
|
||||||
interface RecalledSession {
|
interface RecalledSession {
|
||||||
label: string
|
label: string
|
||||||
retained: number | null
|
retained: number
|
||||||
omitted: number | null
|
omitted: number
|
||||||
truncated: boolean
|
truncated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,15 +475,16 @@ function recalledSessions(source: unknown): RecalledSession[] | null {
|
|||||||
const reference = asRecord(item)
|
const reference = asRecord(item)
|
||||||
if (reference === null) return null
|
if (reference === null) return null
|
||||||
const label = reference['label']
|
const label = reference['label']
|
||||||
if (typeof label !== 'string' || label === '') return null
|
|
||||||
const retained = reference['retainedMessages']
|
const retained = reference['retainedMessages']
|
||||||
const omitted = reference['omittedMessages']
|
const omitted = reference['omittedMessages']
|
||||||
sessions.push({
|
const truncated = reference['truncated']
|
||||||
label,
|
// Completeness is the fact this card exists to report, so a reference that
|
||||||
retained: typeof retained === 'number' ? retained : null,
|
// cannot state it is not a readable recall — showing the label alone would
|
||||||
omitted: typeof omitted === 'number' ? omitted : null,
|
// present a confident card over unknown loss.
|
||||||
truncated: reference['truncated'] === true,
|
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
|
return sessions.length === 0 ? null : sessions
|
||||||
}
|
}
|
||||||
@@ -493,14 +512,12 @@ export function RecallBody({ content, source, t }: {
|
|||||||
{sessions.map((session, index) => (
|
{sessions.map((session, index) => (
|
||||||
<li key={index} className={css.recall}>
|
<li key={index} className={css.recall}>
|
||||||
<span className={css.recallLabel}>{session.label}</span>
|
<span className={css.recallLabel}>{session.label}</span>
|
||||||
{session.retained !== null && session.omitted !== null && (
|
<span className={css.recallCounts}>
|
||||||
<span className={css.recallCounts}>
|
{t('message.context.recall.counts', {
|
||||||
{t('message.context.recall.counts', {
|
retained: session.retained,
|
||||||
retained: session.retained,
|
omitted: session.omitted,
|
||||||
omitted: session.omitted,
|
})}
|
||||||
})}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{session.truncated && (
|
{session.truncated && (
|
||||||
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
|
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
|
||||||
)}
|
)}
|
||||||
@@ -555,7 +572,9 @@ export function contextBody(
|
|||||||
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
|
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
|
||||||
}
|
}
|
||||||
case 'relay':
|
case 'relay':
|
||||||
return { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
|
return relaySender(props.source) === null
|
||||||
|
? opaque
|
||||||
|
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
|
||||||
case 'recall':
|
case 'recall':
|
||||||
return recalledSessions(props.source) === null
|
return recalledSessions(props.source) === null
|
||||||
? opaque
|
? opaque
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const zh = {
|
|||||||
'message.context.instructions.removed': '已移除',
|
'message.context.instructions.removed': '已移除',
|
||||||
'message.context.catalog.replaced': '替换目录',
|
'message.context.catalog.replaced': '替换目录',
|
||||||
'message.context.catalog.more': '…还有 {count} 条',
|
'message.context.catalog.more': '…还有 {count} 条',
|
||||||
|
'message.context.snapshot.supersedes': '取代先前的快照',
|
||||||
'message.context.relay.from': '来自会话 {session}',
|
'message.context.relay.from': '来自会话 {session}',
|
||||||
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
|
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
|
||||||
'message.context.recall.truncated': '已截断',
|
'message.context.recall.truncated': '已截断',
|
||||||
@@ -185,6 +186,7 @@ export const en = {
|
|||||||
'message.context.instructions.removed': 'removed',
|
'message.context.instructions.removed': 'removed',
|
||||||
'message.context.catalog.replaced': 'Replacement catalog',
|
'message.context.catalog.replaced': 'Replacement catalog',
|
||||||
'message.context.catalog.more': '… {count} more',
|
'message.context.catalog.more': '… {count} more',
|
||||||
|
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
|
||||||
'message.context.relay.from': 'From session {session}',
|
'message.context.relay.from': 'From session {session}',
|
||||||
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
|
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
|
||||||
'message.context.recall.truncated': 'truncated',
|
'message.context.recall.truncated': 'truncated',
|
||||||
|
|||||||
@@ -617,6 +617,45 @@ describe('MessageItem arms', () => {
|
|||||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('each form falls back to the opaque body when its required facts are unreadable', () => {
|
||||||
|
// The fallback chain is the load-bearing wall: every dedicated form must
|
||||||
|
// reach it, and the row marker must not claim a form that did not render.
|
||||||
|
const cases = [
|
||||||
|
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
|
||||||
|
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
|
||||||
|
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
|
||||||
|
] as const
|
||||||
|
for (const { form, source, label } of cases) {
|
||||||
|
cleanup()
|
||||||
|
const view = render(
|
||||||
|
<MessageItem t={t} node={{
|
||||||
|
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
|
||||||
|
source, provenance: { role: 'inject', label }, form,
|
||||||
|
} as never}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
|
||||||
|
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
|
||||||
|
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||||
|
.toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a snapshot states the supersession its framing line carries', () => {
|
||||||
|
const view = render(
|
||||||
|
<MessageItem t={t} node={{
|
||||||
|
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
|
||||||
|
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
|
||||||
|
provenance: { role: 'inject', label: 'plugin' },
|
||||||
|
form: 'snapshot',
|
||||||
|
} as never}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||||
|
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
|
||||||
|
.toBe('取代先前的快照')
|
||||||
|
})
|
||||||
|
|
||||||
it('a relay names the agent that sent it above what it said', () => {
|
it('a relay names the agent that sent it above what it said', () => {
|
||||||
const view = render(
|
const view = render(
|
||||||
<MessageItem t={t} node={{
|
<MessageItem t={t} node={{
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import type {
|
|||||||
} from '@deepseek-ai/dsh-llm'
|
} from '@deepseek-ai/dsh-llm'
|
||||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||||
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||||
import { renderContextSections, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import type {} from '@deepseek-ai/dsh-tools'
|
import type {} from '@deepseek-ai/dsh-tools'
|
||||||
import { executeToolCalls } from './tool-calls.ts'
|
import { executeToolCalls } from './tool-calls.ts'
|
||||||
|
|
||||||
@@ -700,7 +700,8 @@ export class ReactLoopAgent implements Agent {
|
|||||||
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
const system = renderPrompt(assembly)
|
const system = renderPrompt(assembly)
|
||||||
materializeRuntimeContext(session, renderContextSnapshot(assembly), renderContextSections(assembly))
|
const sections = renderContextSections(assembly)
|
||||||
|
materializeRuntimeContext(session, joinContextSections(sections), sections)
|
||||||
|
|
||||||
// Commit the exact pending batch only after every asynchronous
|
// Commit the exact pending batch only after every asynchronous
|
||||||
// pre-request contribution succeeded. Input accepted after this splice
|
// pre-request contribution succeeded. Input accepted after this splice
|
||||||
|
|||||||
@@ -208,7 +208,19 @@ export function renderPrompt(assembly: PromptAssembly): string {
|
|||||||
* @returns the current full snapshot, or `''` when no context is active.
|
* @returns the current full snapshot, or `''` when no context is active.
|
||||||
*/
|
*/
|
||||||
export function renderContextSnapshot(assembly: PromptAssembly): string {
|
export function renderContextSnapshot(assembly: PromptAssembly): string {
|
||||||
const body = renderContextSections(assembly).map(section => section.text).join('\n\n')
|
return joinContextSections(renderContextSections(assembly))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model-facing snapshot text for an already-rendered section list.
|
||||||
|
*
|
||||||
|
* A caller that also needs the sections renders them once and joins here, so a
|
||||||
|
* request does not interpolate every context twice.
|
||||||
|
* @param sections - sections from {@link renderContextSections}.
|
||||||
|
* @returns the current full snapshot, or `''` when no context is active.
|
||||||
|
*/
|
||||||
|
export function joinContextSections(sections: readonly ContextSnapshotSection[]): string {
|
||||||
|
const body = sections.map(section => section.text).join('\n\n')
|
||||||
if (body.length === 0) return ''
|
if (body.length === 0) return ''
|
||||||
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
|
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,22 +59,20 @@ export interface GoalClearChangeMeta {
|
|||||||
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
|
||||||
|
|
||||||
/** Message attribution for durable goal state and continuation rounds. */
|
/** Message attribution for durable goal state and continuation rounds. */
|
||||||
export interface GoalMessageSource {
|
export type GoalMessageSource = {
|
||||||
readonly kind: 'goal'
|
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 goalId: GoalId
|
||||||
readonly revision: number
|
readonly revision: number
|
||||||
/** Zero for state changes; positive for admitted continuation rounds. */
|
/** Zero for state changes; positive for admitted continuation rounds. */
|
||||||
readonly round: number
|
readonly round: number
|
||||||
/** Complete durable mutation carried only by round-zero state-change messages. */
|
/** Complete durable mutation carried only by round-zero state-change messages. */
|
||||||
readonly change?: GoalChangeMeta
|
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' {
|
declare module '@deepseek-ai/dsh-llm' {
|
||||||
interface MessageSourceMap {
|
interface MessageSourceMap {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Model-visible rendering for durable goal mutations. */
|
/** Model-visible rendering for durable goal mutations. */
|
||||||
|
|
||||||
|
import { boundContextSummary } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { GoalChangeMeta } from './domain.ts'
|
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.
|
* @returns the operation and, for a surviving goal, its objective.
|
||||||
*/
|
*/
|
||||||
export function goalChangeSummary(change: GoalChangeMeta): string {
|
export function goalChangeSummary(change: GoalChangeMeta): string {
|
||||||
// The row header already names the producer, so the account does not repeat it.
|
// The row header already names the producer, so the account does not repeat
|
||||||
return change.operation === 'clear'
|
// it. The objective is unbounded caller text, so the account is bounded.
|
||||||
|
return boundContextSummary(change.operation === 'clear'
|
||||||
? change.operation
|
? change.operation
|
||||||
: `${change.operation}: ${change.goal.objective}`
|
: `${change.operation}: ${change.goal.objective}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Context } from 'cordis'
|
|||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||||
import type { GoalRef, GoalView } 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 { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||||
@@ -319,7 +319,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
plugin: 'tool-goal',
|
plugin: 'tool-goal',
|
||||||
form: 'notice',
|
form: 'notice',
|
||||||
summary: `${args.action as string}: ${goal.objective}`,
|
summary: boundContextSummary(`${args.action as string}: ${goal.objective}`),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,24 @@ export interface MessageSourceMap {
|
|||||||
tool: ToolMessageSource
|
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). */
|
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
|
||||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import z from 'schemastery'
|
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 { TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
import type { GenericCallView, ToolDefinition, ToolExecution } 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}`
|
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.
|
* One-line account of a settled task for the `notice` form's collapsed row.
|
||||||
* @param snapshot - the settled task.
|
* @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 {
|
function completionSummary(snapshot: TaskSnapshot): string {
|
||||||
const summary = `${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`
|
return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`)
|
||||||
return summary.length <= SUMMARY_MAX_CHARS
|
|
||||||
? summary
|
|
||||||
: `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitCompletionNotice(snapshot: TaskSnapshot): string {
|
function fitCompletionNotice(snapshot: TaskSnapshot): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user