Files
HologramAgent-DSH-Memory-Port/lib/loop/assistant-stream.js
jianf 5c6aaf53cf feat(memory): land the loop ports without wiring them
The four files under lib/loop/ were written against 0.2.1-rc.2 and have been
verified to import on their own; they are the substrate a replacement loop
needs to take over step(). This commit only puts them in the tree — lib/loop.js
is untouched, so nothing imports them yet and behaviour is unchanged.

Two packaging facts they bring with them:

  files  `lib/*.js` does not recurse, so lib/loop/ was shipping nothing.
         `lib/loop.js` and `lib/loop/*.js` are now listed separately because
         neither glob covers the other.
  peer   runtime-context.js imports @deepseek-ai/dsh-session, which the peer
         table never declared. G2b's allowlist covered it, so the static guard
         passed over a real declaration gap.

test/loop-ports.test.js pins the port interface rather than its behaviour: the
modules import alone, the export names survive, and the inbox projection keeps
key "inbox" / stateVersion 1. That literal pair is a wire format shared with
stock-loop sessions — a different key silently reads as "no pending input", a
different version makes stateSchema.parse refuse stored rows. The schema's
parse() is also exercised against seven malformed persisted rows, since
replacing a zod schema with a hand-written check is exactly the kind of change
that can be a no-op and still look fine.
2026-11-04 17:27:36 +08:00

141 lines
3.9 KiB
JavaScript

/**
* Folds one model attempt into one compact stream plus ordered transient frames.
*
* Reproduced from `dsh-agent-loop/lib/types/index.js:352-473` (0.2.1-rc.2, MIT)
* because `dsh-agent-loop` does not export it, and there is no behaviour to
* change here: the framing has to match what `dsh-headless` and the session
* title plugin read off `agent/assistant-stream` byte for byte.
*
* @module dsh-memory/lib/loop/assistant-stream
*/
import { AssistantStreamAccumulator, BlockAssembler, LlmAttemptId } from '@deepseek-ai/dsh-llm'
export class AssistantStreamAttempt {
/** @type {() => number} */
nextRevision
/** @type {number} */
turn
/** @type {number} */
step
/** @type {(frame: object) => void} */
emit
accumulator = new AssistantStreamAccumulator()
assembler = new BlockAssembler()
index = 0
terminal = false
/** Attempt identity unique within this Agent lifecycle. */
attemptId
/** Whether this started attempt has emitted its terminal frame. */
get ended() {
return this.terminal
}
/**
* @param {string} sessionId identity embedded only in the Agent-lifecycle-local attempt id
* @param {number} attempt attached-Session-local attempt counter
* @param {() => number} nextRevision allocates the next emitted frame revision
* @param {number} turn durable turn owning the request
* @param {number} step durable step owning the request
* @param {(frame: object) => void} emit agent-scoped notification publisher
*/
constructor(sessionId, attempt, nextRevision, turn, step, emit) {
this.nextRevision = nextRevision
this.turn = turn
this.step = step
this.emit = emit
this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`)
}
/** Publish the opening marker before the first delivered chunk. */
start() {
this.emit({
type: 'start',
attemptId: this.attemptId,
revision: this.nextRevision(),
turn: this.turn,
step: this.step,
})
}
/** Snapshot one chunk once, then feed durable compaction, assembly, and live publication. */
push(chunk) {
const timed = this.accumulator.push({ time: Date.now(), chunk })
this.assembler.push(timed.chunk)
this.emit({
type: 'chunk',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index++,
time: timed.time,
chunk: timed.chunk,
})
}
/**
* Publish terminal settlement after the matching durable event commits.
* @param {string} eventType durable settlement type
* @param {() => number} append synchronous durable append returning its committed seq
*/
settle(eventType, append) {
let seq
try {
seq = append()
} catch (error) {
this.abandon()
throw error
}
this.terminal = true
this.emit({
type: 'end',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index,
outcome: { kind: 'committed', eventType, seq },
})
}
/** Publish abandonment when no durable attempt event can be committed. */
abandon() {
this.terminal = true
this.emit({
type: 'end',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index,
outcome: { kind: 'abandoned' },
})
}
/** Exact compact stream for the final durable event. */
get stream() {
return [...this.accumulator.snapshot()]
}
/** Canonical completed-message blocks from the same chunks. */
blocks() {
return this.assembler.blocks()
}
/** Safe visible prefix when cancellation interrupts the attempt. */
interruptedBlocks() {
return this.assembler.interruptedBlocks()
}
/** Latest adapter-reported usage in the stream. */
get usage() {
return this.assembler.usage
}
/** Terminal reason, defaulting to stop when the stream omitted one. */
get finish() {
return this.assembler.finish
}
/** Replay state carried by the terminal finish record. */
get replayState() {
return this.assembler.replayState
}
}