diff --git a/lib/loop.js b/lib/loop.js new file mode 100644 index 0000000..15f924e --- /dev/null +++ b/lib/loop.js @@ -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 diff --git a/package.json b/package.json index c68e5f2..89d2541 100644 --- a/package.json +++ b/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 diff --git a/scripts/link-dsh.sh b/scripts/link-dsh.sh index 4d09f88..6d39b1a 100755 --- a/scripts/link-dsh.sh +++ b/scripts/link-dsh.sh @@ -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 diff --git a/test/loop.test.js b/test/loop.test.js new file mode 100644 index 0000000..5a20650 --- /dev/null +++ b/test/loop.test.js @@ -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') +}) diff --git a/test/phase-b-g-static.test.js b/test/phase-b-g-static.test.js index 4a2de6b..2edf2a9 100644 --- a/test/phase-b-g-static.test.js +++ b/test/phase-b-g-static.test.js @@ -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')