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.
139 lines
6.3 KiB
JavaScript
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
|