Files
HologramAgent-DSH-Memory-Port/lib/loop.js
jianf 6d9ad62a8f feat(memory): surface recall as a system section instead of history
Per-turn recall currently enters through the `agent/pre-step` waterfall, and
that hook can only return `messages`. The recall block 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 — it runs before the
turn text is known, so it can only render input-independent text.

The loop's `preStep` is the first seam holding BOTH the assembled prompt and
this turn's claimed messages. `lib/loop.js` subclasses the harness AgentLoop and
wraps that seam on the agent instance: a pure snapshot carrier message is moved
out of `messages` and into `assembly.sections`, so it becomes part of the system
prompt for that turn and never enters history.

Deliberately decoupled from the memory plugin: nothing imports it or matches its
name. Promotion keys off the shape every snapshot-style injector already uses
(`source.sections`, the same shape `dsh-time-context` emits).

Constraints honoured, each of which was a measured failure elsewhere:
- The promoted section carries `interpolate: false`; recalled text is free text
  and may contain `{{...}}`, which the strict interpolator rejects.
- The subclass defines no constructor and no static `inject`. The parent already
  registers the turnBoundary + inbox projections, calls `setFactory(this)`, and
  declares `{{provider}}`/`{{model}}`; re-registering any of them throws at
  startup.
- Only the instance's `preStep` is patched, never the prototype, which other
  agents share.

Also wires the new peer: `scripts/link-dsh.sh` gains dsh-agent/dsh-agent-loop
(without them the package cannot resolve at test time), package.json declares
dsh-agent-loop as a peer and adds the new test file, and G2b's allowlist admits
dsh-agent-loop as a first-party harness package, with the reason recorded.

npm test: 247 pass, 0 fail.
2026-11-04 14:54:49 +08:00

139 lines
6.3 KiB
JavaScript

/**
* 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