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.
132 lines
5.4 KiB
JavaScript
132 lines
5.4 KiB
JavaScript
/**
|
|
* 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')
|
|
})
|