The four files under lib/loop/ were written against 0.2.1-rc.2 and have been
verified to import on their own; they are the substrate a replacement loop
needs to take over step(). This commit only puts them in the tree — lib/loop.js
is untouched, so nothing imports them yet and behaviour is unchanged.
Two packaging facts they bring with them:
files `lib/*.js` does not recurse, so lib/loop/ was shipping nothing.
`lib/loop.js` and `lib/loop/*.js` are now listed separately because
neither glob covers the other.
peer runtime-context.js imports @deepseek-ai/dsh-session, which the peer
table never declared. G2b's allowlist covered it, so the static guard
passed over a real declaration gap.
test/loop-ports.test.js pins the port interface rather than its behaviour: the
modules import alone, the export names survive, and the inbox projection keeps
key "inbox" / stateVersion 1. That literal pair is a wire format shared with
stock-loop sessions — a different key silently reads as "no pending input", a
different version makes stateSchema.parse refuse stored rows. The schema's
parse() is also exercised against seven malformed persisted rows, since
replacing a zod schema with a hand-written check is exactly the kind of change
that can be a no-op and still look fine.
146 lines
5.7 KiB
JavaScript
146 lines
5.7 KiB
JavaScript
/**
|
|
* The two surface projections the loop owns: the rendered system prompt and
|
|
* the dynamic runtime-context snapshot.
|
|
*
|
|
* ## Why this is a copy and not an import
|
|
*
|
|
* `dsh-agent-loop` does not export either class (its entry exports only
|
|
* `AgentLoop`, the constants and `turnBoundaryProjectionDefinition`,
|
|
* dsh-agent-loop/lib/index.js:2023). They are nevertheless required: without
|
|
* `SystemPromptProjection` the prompt would never reach surface node 0, and
|
|
* without `RuntimeContextProjection` every step would append a duplicate
|
|
* snapshot. Both are reproduced from
|
|
* `dsh-agent-loop/lib/types/index.js:211-351` (0.2.1-rc.2, MIT) unchanged.
|
|
*
|
|
* ★ `SOURCE = 'runtime-context'` is load-bearing and must not be renamed: the
|
|
* stock loop also owns snapshots under that `source.kind`, so a session that
|
|
* switches between the stock loop and this one (a profile patch applied
|
|
* mid-life) must still recognise the previous process's snapshot as its own.
|
|
* A different kind would leave two snapshots in the surface, both claiming to
|
|
* supersede the other.
|
|
*
|
|
* @module dsh-memory/lib/loop/runtime-context
|
|
*/
|
|
|
|
import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
const SOURCE = 'runtime-context'
|
|
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
|
|
|
/** @param {object} message derived message */
|
|
function isOwned(message) {
|
|
return message.source.kind === SOURCE
|
|
}
|
|
|
|
/** @param {object} message derived message */
|
|
function textOf(message) {
|
|
const [block] = message.content
|
|
return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
|
|
}
|
|
|
|
/** Committed events from the newest backward; the restore scans stop at the first match. */
|
|
function eventsNewestFirst(session) {
|
|
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.
|
|
*/
|
|
export class SystemPromptProjection {
|
|
/** @param {object} session */
|
|
constructor(session) {
|
|
this.session = session
|
|
}
|
|
|
|
/** The surviving `system/message` nodes in surface order. */
|
|
systemNodes() {
|
|
const nodes = []
|
|
for (const seq of this.session.surface.nodes) {
|
|
const event = this.session.eventAt(seq)
|
|
if (event?.type !== 'system/message') continue
|
|
const text = event.data.message.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 {string} rendered the fully rendered system prompt; `''` when none is active
|
|
* @param {{inHistory: boolean, startsSeries: boolean}} input route capability and series facts
|
|
*/
|
|
project(rendered, input) {
|
|
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' } }]
|
|
}
|
|
|
|
/**
|
|
* @param {number} seq surface node to replace
|
|
* @param {string} text replacement text
|
|
*/
|
|
replace(seq, text) {
|
|
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. */
|
|
retained
|
|
|
|
/**
|
|
* Restore projection state once, then follow authoritative session events.
|
|
* @param {object} ctx agent-scoped event context
|
|
* @param {object} session session receiving projected messages
|
|
*/
|
|
constructor(ctx, 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 {string} current fully rendered dynamic context
|
|
* @param {Array<{name: string, text: string}>} sections named contributions of the snapshot
|
|
* @returns {object|undefined} a candidate user message, or undefined when no update is needed
|
|
*/
|
|
project(current, sections) {
|
|
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: sections.length === 0 ? { kind: SOURCE } : { kind: SOURCE, form: 'snapshot', sections },
|
|
})
|
|
}
|
|
}
|