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.
This commit is contained in:
138
lib/loop.js
Normal file
138
lib/loop.js
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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
|
||||
15
package.json
15
package.json
@ -15,19 +15,26 @@
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/store.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js test/phase-b-h.test.js test/memory-log.test.js",
|
||||
"test": "node --test test/store.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js test/phase-b-h.test.js test/memory-log.test.js test/loop.test.js",
|
||||
"check": "node scripts/checkpoints.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.5",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.2.1-rc.2",
|
||||
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
|
||||
"@deepseek-ai/dsh-tools": "0.2.1-rc.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-system-prompt": { "optional": true },
|
||||
"@deepseek-ai/dsh-tools": { "optional": true },
|
||||
"@deepseek-ai/dsh-llm": { "optional": true }
|
||||
"@deepseek-ai/dsh-system-prompt": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tools": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-llm": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"private": true
|
||||
|
||||
@ -14,6 +14,8 @@ set -euo pipefail
|
||||
GLOBAL_ROOT="${1:-/usr/lib/node_modules}"
|
||||
PEERS=(
|
||||
cordis
|
||||
dsh-agent
|
||||
dsh-agent-loop
|
||||
cosmokit
|
||||
dsh-llm
|
||||
dsh-session
|
||||
|
||||
131
test/loop.test.js
Normal file
131
test/loop.test.js
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Behavioural contract for the loop that promotes snapshot sections.
|
||||
*
|
||||
* The loop only earns its keep if a promoted recall block reaches the SYSTEM
|
||||
* prompt and does NOT reach history. These tests pin exactly that, plus the
|
||||
* cases where it must keep its hands off.
|
||||
*
|
||||
* P1 a pure snapshot carrier message moves into `assembly.sections`.
|
||||
* P2 the returned decision is a NEW object; the input is not mutated.
|
||||
* P3 the promoted section carries `interpolate: false` — free text with
|
||||
* `{{...}}` must not reach the strict interpolator.
|
||||
* P4 a message with real content blocks is NOT promoted (plugin said
|
||||
* "user-visible").
|
||||
* P5 a non-snapshot message is NOT promoted.
|
||||
* P6 a name collision suffixes instead of overwriting or dropping.
|
||||
* P7 `reject` decisions pass through untouched.
|
||||
* P8 a decision with no `assembly` passes through untouched.
|
||||
* P9 everything that is not promoted survives in `messages`, in order.
|
||||
* P10 the class does not re-register the factory (no constructor / inject).
|
||||
*
|
||||
* @module dsh-memory/test/loop
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { HologramAgentLoop, promoteSnapshotSections } from '../lib/loop.js'
|
||||
|
||||
/** Build a user message shaped like a snapshot-style injector emits. */
|
||||
function snapshotMessage(text, kind = 'memory') {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [],
|
||||
source: { kind, form: 'snapshot', sections: [{ name: kind, text }] },
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a plain user message with real content. */
|
||||
function plainMessage(text) {
|
||||
return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
|
||||
}
|
||||
|
||||
/** A minimal decision carrying an assembly with one existing section. */
|
||||
function decisionWith(messages, sections = [{ name: 'base', text: 'base prompt' }]) {
|
||||
return { kind: 'enter', messages, assembly: { sections, variables: {}, tools: [] } }
|
||||
}
|
||||
|
||||
test('P1: a pure snapshot carrier is promoted into assembly.sections', () => {
|
||||
const decision = decisionWith([plainMessage('what did I decide?'), snapshotMessage('note alpha')])
|
||||
const out = promoteSnapshotSections(decision)
|
||||
|
||||
assert.equal(out.messages.length, 1)
|
||||
assert.deepEqual(out.messages[0].content, [{ type: 'text', text: 'what did I decide?' }])
|
||||
assert.equal(out.assembly.sections.length, 2)
|
||||
const promoted = out.assembly.sections[1]
|
||||
assert.equal(promoted.text, 'note alpha')
|
||||
assert.ok(promoted.name.startsWith('memory'))
|
||||
})
|
||||
|
||||
test('P2: the input decision is not mutated', () => {
|
||||
const original = decisionWith([snapshotMessage('note alpha')])
|
||||
const before = structuredClone(original)
|
||||
promoteSnapshotSections(original)
|
||||
assert.deepEqual(original, before)
|
||||
})
|
||||
|
||||
test('P3: the promoted section opts out of interpolation', () => {
|
||||
const out = promoteSnapshotSections(decisionWith([snapshotMessage('literal {{not-a-var}} here')]))
|
||||
assert.equal(out.assembly.sections[1].interpolate, false)
|
||||
})
|
||||
|
||||
test('P4: a message carrying real content blocks is not promoted', () => {
|
||||
const mixed = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'visible' }],
|
||||
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] },
|
||||
}
|
||||
const out = promoteSnapshotSections(decisionWith([mixed]))
|
||||
assert.equal(out.messages.length, 1)
|
||||
assert.equal(out.assembly.sections.length, 1)
|
||||
})
|
||||
|
||||
test('P5: a non-snapshot message is not promoted', () => {
|
||||
const runtime = {
|
||||
role: 'user',
|
||||
content: [],
|
||||
source: { kind: 'runtime-context', form: 'inline', sections: [{ name: 'x', text: 'y' }] },
|
||||
}
|
||||
const out = promoteSnapshotSections(decisionWith([runtime]))
|
||||
assert.equal(out.messages.length, 1)
|
||||
assert.equal(out.assembly.sections.length, 1)
|
||||
})
|
||||
|
||||
test('P6: a name collision is suffixed, never dropped', () => {
|
||||
const out = promoteSnapshotSections(
|
||||
decisionWith([snapshotMessage('note', 'base')], [{ name: 'base', text: 'base prompt' }]),
|
||||
)
|
||||
assert.equal(out.assembly.sections.length, 2)
|
||||
const names = out.assembly.sections.map((section) => section.name)
|
||||
assert.equal(new Set(names).size, names.length, 'section names must stay unique')
|
||||
assert.equal(out.assembly.sections[1].text, 'note')
|
||||
})
|
||||
|
||||
test('P7: a reject decision passes through untouched', () => {
|
||||
const reject = { kind: 'reject', reason: 'nope' }
|
||||
assert.equal(promoteSnapshotSections(reject), reject)
|
||||
})
|
||||
|
||||
test('P8: a decision without an assembly passes through untouched', () => {
|
||||
const bare = { kind: 'enter', messages: [snapshotMessage('note')] }
|
||||
assert.equal(promoteSnapshotSections(bare), bare)
|
||||
})
|
||||
|
||||
test('P9: non-promoted messages keep their order', () => {
|
||||
const out = promoteSnapshotSections(
|
||||
decisionWith([plainMessage('first'), snapshotMessage('note'), plainMessage('second')]),
|
||||
)
|
||||
assert.deepEqual(
|
||||
out.messages.map((message) => message.content[0].text),
|
||||
['first', 'second'],
|
||||
)
|
||||
})
|
||||
|
||||
test('P10: the subclass does not re-declare the factory or its inject list', () => {
|
||||
// A second `setFactory` registration throws at startup, and re-declaring
|
||||
// `inject` would pull a duplicate AgentLoop into the container. Both are
|
||||
// avoided by not defining a constructor or static inject at all.
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(HologramAgentLoop, 'inject'), false)
|
||||
const source = HologramAgentLoop.prototype.constructor.toString()
|
||||
assert.equal(source.includes('constructor'), false, 'subclass must not define a constructor')
|
||||
assert.equal(typeof HologramAgentLoop.prototype.prepare, 'function')
|
||||
})
|
||||
@ -39,7 +39,12 @@ const rel = (file) => file.replace(LIB, 'lib/')
|
||||
test('G2b: lib/ imports only node: builtins and cordis/dsh peers', () => {
|
||||
// A new runtime dependency (an ONNX binding, an HTTP client, a vector DB) shows
|
||||
// up here before it shows up in a deployment.
|
||||
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope)|\.\/|\.\.\/)/
|
||||
// `dsh-agent-loop` joined this list with lib/loop.js: the loop subclass has to
|
||||
// extend the harness's own AgentLoop, and that package is declared as a peer in
|
||||
// package.json exactly like dsh-tools. It is still a first-party harness
|
||||
// package, not the kind of new runtime dependency (a binding, an HTTP client,
|
||||
// a vector DB) this guard exists to catch.
|
||||
const allowed = /^(node:|@deepseek-ai\/(cordis|dsh-llm|dsh-system-prompt|dsh-tools|dsh-session|dsh-scope|dsh-agent-loop)|\.\/|\.\.\/)/
|
||||
const offenders = []
|
||||
for (const file of libSources()) {
|
||||
const text = readFileSync(file, 'utf8')
|
||||
|
||||
Reference in New Issue
Block a user