/** * HologramAgent loop: surface recall as a SYSTEM section instead of history. * * Why this exists * --------------- * `dsh-memory` injects per-turn recall through the `agent/pre-step` waterfall, * and that hook can only return `messages`. Whatever it adds therefore lands in * conversation history as a user message — the model reads its own memory as if * a user had said it. `systemPrompt.context` cannot fix this either: that hook * runs before the turn text is known (documented in dsh-memory's own module * note), so it can only render input-INDEPENDENT text. * * The loop's own `preStep` is the first place that has BOTH the assembled * prompt and the claimed messages for this turn. So the swap point is here: * let the existing pre-step hooks run unchanged, then move the recall block out * of `messages` and into `assembly.sections`. The memory becomes part of the * system prompt for that turn, and the user message never enters history. * * Coupling * -------- * None, deliberately. We do not import the memory plugin or match its name; we * recognise the *shape* every snapshot-style injector already uses * (`source.sections = [{ name, text }]`), which is the same shape * `dsh-time-context` emits. Anything a plugin chose to label that way is * promoted; everything else passes through untouched. */ import { AgentLoop } from '@deepseek-ai/dsh-agent-loop' /** Suffix appended to the promoted section name, keeping provenance visible. */ const PROMOTED_SUFFIX = ':system' /** * Split one message's `source.sections` out of the message itself. * * A message is promoted only when it is a pure carrier: snapshot form, a * non-empty sections array, and no content blocks of its own. A message that * carries both text and sections is the plugin saying "this is a user-visible * turn" — touching it would delete something the plugin meant to keep. * * @param {object} message a user message from a pre-step decision * @returns {{text: string, name: string} | null} */ function promotedSection(message) { const source = message === null || typeof message !== 'object' ? undefined : message.source if (source === null || typeof source !== 'object') return null if (source.form !== 'snapshot') return null if (!Array.isArray(source.sections) || source.sections.length === 0) return null const blocks = Array.isArray(message.content) ? message.content : [] if (blocks.length > 0) return null const parts = [] for (const section of source.sections) { if (section !== null && typeof section === 'object' && typeof section.text === 'string' && section.text !== '') { parts.push(section.text) } else if (typeof section === 'string' && section !== '') parts.push(section) } if (parts.length === 0) return null const name = typeof source.kind === 'string' && source.kind !== '' ? source.kind : 'memory' return { text: parts.join('\n\n'), name } } /** * Rewrite one pre-step decision so snapshot sections live in the prompt. * * The returned object is a NEW decision: `super.step()` keeps the one it * received, and the loop's phase bookkeeping reads `assembly` off whatever it * is handed. Mutating in place would work today and would silently break the * first time the loop reuses a decision. * * @param {object} decision the decision returned by the pre-step waterfall * @returns {object} the same decision shape with promoted sections moved */ export function promoteSnapshotSections(decision) { if (decision === null || typeof decision !== 'object') return decision if (decision.kind === 'reject') return decision const messages = Array.isArray(decision.messages) ? decision.messages : [] const assembly = decision.assembly if (assembly === null || typeof assembly !== 'object' || !Array.isArray(assembly.sections)) return decision const kept = [] const promoted = [] for (const message of messages) { const section = promotedSection(message) if (section === null) kept.push(message) else promoted.push(section) } if (promoted.length === 0) return decision const sections = [...assembly.sections] const seen = new Set(sections.map((section) => section.name)) for (const section of promoted) { // A name collision means some other producer already owns that slot. The // prompt layer throws on duplicates, so suffix rather than overwrite: // dropping the content would hide memory with no visible failure. let name = `${section.name}${PROMOTED_SUFFIX}` for (let n = 2; seen.has(name); n += 1) name = `${section.name}${PROMOTED_SUFFIX}${n}` seen.add(name) // `interpolate: false` is mandatory, not cosmetic: recalled notes are free // text and may legitimately contain `{{...}}`, which the strict interpolator // rejects with `unknown prompt variable` (measured — dsh-memory hit this). sections.push({ name, text: section.text, interpolate: false }) } return { ...decision, messages: kept, assembly: { ...assembly, sections } } } /** * The headless AgentLoop, with snapshot sections promoted into the prompt. * * No constructor and no static `inject`: the parent already registers the * turnBoundary + inbox projections, calls `agents.setFactory(this)`, and * declares the `{{provider}}` / `{{model}}` variables. Re-declaring any of them * here would be the bug — `setFactory` throws if a factory is already * registered, so a second registration is exactly what breaks startup. */ export class HologramAgentLoop extends AgentLoop { /** * Prepare one agent, then wrap its pre-step seam. * * Patching the *instance* (not the prototype) is deliberate: the prototype is * shared with every agent the base class creates, including ones this plugin * must not touch. An own property shadows it for this agent only. * * @param {...unknown} args forwarded verbatim to the parent * @returns {{agent: object, signal: AbortSignal, publish: Function}} */ prepare(...args) { const prepared = super.prepare(...args) const agent = prepared === null || typeof prepared !== 'object' ? undefined : prepared.agent if (agent === undefined || typeof agent.preStep !== 'function') return prepared const original = agent.preStep.bind(agent) agent.preStep = async (target, position) => promoteSnapshotSections(await original(target, position)) return prepared } } export default HologramAgentLoop