Clarify the SystemPromptDecisionInput.startsSeries contract (a new request series no longer implies consolidation), record the tool-schema series-start exception consistently in the decision and consequences, and correct the complete-declaration attribution to the unsupported-route projection.
170 lines
7.4 KiB
TypeScript
170 lines
7.4 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 starts a new request series: a pre-step listener declared
|
|
* one, the surface changed since the last request, or assembled tools changed
|
|
* on a route without tool-update support.
|
|
*/
|
|
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, a cleared prompt, or a new series whose
|
|
* effective text differs instead normalizes the first system node and empties
|
|
* later active nodes. A series start whose latest surviving node already holds
|
|
* the rendering emits nothing: moving the same text to node 0 would change the
|
|
* request prefix without changing the prompt. 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.
|
|
* A series start re-baselines node 0 only when the effective text differs; a
|
|
* capable continuing series keeps appended nodes in place to preserve the prefix.
|
|
* @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 || rendered.length === 0 || (input.startsSeries && latest.text !== rendered)) {
|
|
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 },
|
|
})
|
|
}
|
|
}
|