Give durable user context its producer-owned source kind instead of a generic plugin wrapper. The V3-to-V4 conversion preserves the frozen rename and collision rules, all own JSON metadata, event coordinates, and historical request-only kinds. Native catalog and JSONL admission retain source validation so unknown attribution can survive without its producer while malformed owned sources are refused. Use an explicit user-only RequestUserInput for auxiliary LLM prompts that do not own durable message identity. Auto Review and compaction remove their request-only source registrations while retaining prompt content and freezing; provider serializers and projections keep the same wire data, assistant replay isolation, image ordering, and tool correlation. Session writes, Agent delivery, and recorded title requests continue to require identified durable messages. Bind the saved compatibility policy only to user sources and qualify tmux location attribution without weakening fixed system, model, or tool semantics. Rebuild the current inventory and unpublished V4 acknowledgement on the actual tool-role parent, preserving its rationale and all accepted historical records. The catalog format version records tooling metadata; Session remains V4. Developer-role policy and activation belong to their own feature. The latest-master Messages adapter, serialization, request-input typing, EpochHeader reservation, and attribution checks pass 103 tests across five files. The retained V3 Messages input-history snapshot passes through the source profile; Host/Client typechecks and all 41 documentation gates pass. The generated comparison records four changed roots and 447 unchanged type fingerprints. Latest-master ARIA changes are retained, with producer source labels applied independently; new Messages fixtures retain their producer names as declared source kinds and use the fixed system-message helper; recorded Session generations are unchanged by this regrouping. Co-authored-by: Tianyi Cui <[email protected]>
165 lines
7.0 KiB
TypeScript
165 lines
7.0 KiB
TypeScript
/**
|
|
* Durable projection state for the two loop-owned surface messages the system
|
|
* prompt plugin forms: the system prompt (surface node 0 and any in-history
|
|
* replacement) and the dynamic runtime-context snapshot.
|
|
* @module @deepseek-ai/dsh-agent-loop/runtime-context
|
|
*/
|
|
|
|
import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { ContextFormed, ContextSnapshotSection, Message } from '@deepseek-ai/dsh-llm'
|
|
import type { Session, SessionEvent, SessionSeq, SurfaceIntent, SystemMessage, UserMessage } from '@deepseek-ai/dsh-session'
|
|
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
declare module '@deepseek-ai/dsh-llm' {
|
|
interface MessageSourceMap {
|
|
'runtime-context': { kind: 'runtime-context' } & ContextFormed
|
|
}
|
|
}
|
|
|
|
const SOURCE = 'runtime-context'
|
|
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
|
|
|
function isOwned(message: UserMessage): boolean {
|
|
return message.source.kind === SOURCE
|
|
}
|
|
|
|
function textOf(message: Message): string | undefined {
|
|
const [block] = message.content
|
|
return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
|
|
}
|
|
|
|
/** One uncommitted system-prompt surface operation for request admission or reconciliation. */
|
|
export interface SystemPromptCommit {
|
|
/** Rendered prompt or empty content: an empty head records no prompt; empty tails are dormant. */
|
|
message: SystemMessage
|
|
/** `append` for a new system node, otherwise a replacement of one surviving system node. */
|
|
intent: SurfaceIntent<'system/message'>
|
|
}
|
|
|
|
/** The request-series facts one prompt decision is made under. */
|
|
export interface SystemPromptDecisionInput {
|
|
/** Whether the prepared route for this attempt reads a later `system` message as the effective prompt. */
|
|
inHistory: boolean
|
|
/**
|
|
* Whether this step's request starts a new model-message series: a pre-step
|
|
* listener declared one, the surface was replaced since the last request, or
|
|
* the assembled tool schemas differ from the logged header.
|
|
*/
|
|
startsSeries: boolean
|
|
}
|
|
|
|
/** Committed events from the newest backward; the restore scans stop at the first match. */
|
|
function eventsNewestFirst(session: Session): readonly SessionEvent[] {
|
|
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
|
return session.snapshotEvents().toReversed()
|
|
}
|
|
|
|
/**
|
|
* Decides how a rendered system prompt reaches the surface without owning the
|
|
* commit. The first prompt, even empty, reserves surface node 0.
|
|
* A capable continuing series appends changed nonempty text after the
|
|
* cached history. An incapable route, broken series, or cleared prompt instead
|
|
* normalizes the first system node and empties later active nodes. Dormant empty
|
|
* tails do not supply effective text or require repeated replacements.
|
|
*/
|
|
export class SystemPromptProjection {
|
|
constructor(private readonly session: Session) {}
|
|
|
|
/** The surviving `system/message` nodes in surface order. */
|
|
private systemNodes(): { seq: SessionSeq; text: string | undefined }[] {
|
|
const nodes: { seq: SessionSeq; text: string | undefined }[] = []
|
|
for (const seq of this.session.surface.nodes) {
|
|
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
|
const event = this.session.eventAt(seq)
|
|
if (event?.type !== 'system/message') continue
|
|
const content = event.data.message.content
|
|
const text = content.length === 0 ? '' : textOf(event.data.message)
|
|
nodes.push({ seq, text })
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
/**
|
|
* Reconcile effective text and retained nodes with the prepared route and series.
|
|
* @param rendered - the fully rendered system prompt; `''` when none is active.
|
|
* @param input - the route capability and series facts for this step.
|
|
* @returns ordered per-node updates; an empty list means no update is needed.
|
|
*/
|
|
project(rendered: string, input: SystemPromptDecisionInput): SystemPromptCommit[] {
|
|
const nodes = this.systemNodes()
|
|
const head = nodes[0]
|
|
if (head === undefined) {
|
|
return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
|
|
}
|
|
const latest = nodes.findLast(node => node.text !== '') ?? head
|
|
if (!input.inHistory || input.startsSeries || rendered.length === 0) {
|
|
const updates = nodes.slice(1).filter(node => node.text !== '')
|
|
.map(node => this.replace(node.seq, ''))
|
|
if (head.text !== rendered) updates.push(this.replace(head.seq, rendered))
|
|
return updates
|
|
}
|
|
if (latest.text === rendered) return []
|
|
return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
|
|
}
|
|
|
|
private replace(seq: SessionSeq, text: string): SystemPromptCommit {
|
|
return {
|
|
message: createSystemMessage(text),
|
|
intent: { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Tracks the last retained runtime-context snapshot without owning its commit. */
|
|
export class RuntimeContextProjection {
|
|
/** `undefined` means no snapshot ever existed; `null` means none is retained. */
|
|
private retained: { seq: SessionSeq; text: string | undefined } | null | undefined
|
|
|
|
/**
|
|
* Restore projection state once, then follow authoritative session events.
|
|
* @param ctx - agent-scoped event context.
|
|
* @param session - session receiving projected messages.
|
|
*/
|
|
constructor(ctx: Context, session: Session) {
|
|
const surface = new Set(session.surface.nodes)
|
|
for (const event of eventsNewestFirst(session)) {
|
|
if (event.type !== 'user/message' || !isOwned(event.data)) continue
|
|
this.retained ??= null
|
|
if (surface.has(event.seq)) {
|
|
this.retained = { seq: event.seq, text: textOf(event.data) }
|
|
break
|
|
}
|
|
}
|
|
|
|
ctx.on('session/event', (subject, event) => {
|
|
if (subject !== session) return
|
|
if (event.type === 'user/message' && isOwned(event.data)) {
|
|
this.retained = { seq: event.seq, text: textOf(event.data) }
|
|
} else if (this.retained
|
|
&& isReplacementSurfaceEvent(event)
|
|
&& event.sourceEventSeqs?.includes(this.retained.seq) === true) {
|
|
this.retained = null
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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, 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 }],
|
|
// The cleared marker has no contributions left to attribute.
|
|
source: sections.length === 0
|
|
? { kind: SOURCE }
|
|
: { kind: SOURCE, form: 'snapshot', sections },
|
|
})
|
|
}
|
|
}
|