Files
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

256 lines
9.8 KiB
JavaScript

/**
* Durable agent-inbox projection and its command facade.
*
* ## Why this file exists at all
*
* `dsh-agent-loop` keeps `inboxProjectionDefinition` module-private: its
* package entry exports only `AgentLoop`, `DEFAULT_MAX_PARALLEL_TOOL_CALLS`,
* `CONFIGURED_AGENT_IDENTITIES_KEY` and `turnBoundaryProjectionDefinition`
* (dsh-agent-loop/lib/index.js:2023). A replacement loop therefore cannot
* import the fold, and re-deriving pending input in memory instead of in a
* projection would lose queued messages on `resume` — the pending splices live
* in the durable log, so the state has to be rebuilt from it.
*
* So the fold is reproduced here, field for field, from
* `dsh-agent-loop/lib/index.js:26-60` (0.2.1-rc.2, MIT). The projection KEY
* must stay `"inbox"` and `stateVersion` must stay 1: a different key would
* silently read as "no pending input" for sessions written by the stock loop,
* and a different version would make `stateSchema.parse` refuse those rows.
*
* ★ The one deliberate deviation: the host validates with zod
* (`z.array(z.custom())`), which is a dependency this plugin does not carry.
* {@link inboxProjectionSchema} provides the same contract the projection
* registry actually uses — `parse()` that throws on a malformed persisted row —
* without pulling zod in. Nothing else about the fold changed.
*
* @module dsh-memory/lib/loop/inbox
*/
/**
* Validate one persisted inbox state.
*
* The registry calls `stateSchema.parse` on durable checkpoint rows and on the
* value a fold returns, so this must reject anything that is not the two-array
* shape. Message contents stay opaque (`z.custom()` in the host): they are
* already validated by the session layer when they were added to the surface,
* and re-validating them here would make the fold able to reject a log the
* session accepted.
*/
export const inboxProjectionSchema = {
/**
* @param {unknown} value candidate state
* @returns {{'next-turn': Array<object>, 'next-step': Array<object>}} the same object
* @throws {Error} when the shape is not two message arrays
*/
parse(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('inbox projection state must be an object')
}
const nextTurn = value['next-turn']
const nextStep = value['next-step']
if (!Array.isArray(nextTurn) || !Array.isArray(nextStep)) {
throw new Error('inbox projection state must hold "next-turn" and "next-step" message arrays')
}
return value
},
}
/** Standard fold that reconstructs pending input and rejects invalid splice history. */
export const inboxProjectionDefinition = {
key: 'inbox',
stateSchema: inboxProjectionSchema,
init: () => ({
'next-turn': [],
'next-step': [],
}),
apply(state, event) {
if (event.type !== 'agent/inbox/spliced') return state
const splice = event.data
try {
const inbox = state[splice.target]
const removedCount = splice.removedCount ?? 0
if (
!Number.isSafeInteger(splice.start) ||
splice.start < 0 ||
splice.start > inbox.length ||
!Number.isSafeInteger(removedCount) ||
removedCount < 0 ||
splice.start + removedCount > inbox.length
) {
throw new Error('invalid inbox splice')
}
const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
const ids = new Set()
for (const message of splice.target === 'next-turn' ? [...next, ...state['next-step']] : [...state['next-turn'], ...next]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
return splice.target === 'next-turn'
? { 'next-turn': next, 'next-step': state['next-step'] }
: { 'next-turn': state['next-turn'], 'next-step': next }
} catch (error) {
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
}
},
wire: {
viewSchema: inboxProjectionSchema,
view: (state) => state,
},
stateVersion: 1,
}
/**
* Driver-owned durable Inbox implementation.
*
* Every mutation is a durable `agent/inbox/spliced` append; the projection is
* the only state holder, so a resumed session sees exactly the pending input
* the previous process left behind.
*/
export class ReactLoopInbox {
/**
* @param {object} projections `ctx.sessionProjections`
* @param {object} session the owning session
* @param {object} dispatch agent-scoped dispatcher
*/
constructor(projections, session, dispatch) {
this.projections = projections
this.session = session
this.dispatch = dispatch
}
/** Prompts awaiting individual turns. */
get nextTurn() {
return this.current()['next-turn']
}
/** Input awaiting the next step boundary. */
get nextStep() {
return this.current()['next-step']
}
/** Whether either pending-message list contains work. */
get hasPending() {
const state = this.current()
return state['next-turn'].length > 0 || state['next-step'].length > 0
}
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear() {
this.splice('next-step', 0, this.nextStep.length, [])
this.splice('next-turn', 0, this.nextTurn.length, [])
}
/**
* Remove and return the complete batch proposed for one step.
* @param {'next-turn'|'next-step'} target whether this boundary also consumes one queued turn
* @param {number} turn turn that will own the claimed batch
*/
claim(target, turn) {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false))
for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn })
return claimed
}
/**
* Append one message to a pending list.
* @param {'next-turn'|'next-step'} target pending list to extend
* @param {object} message message to append
*/
append(target, message) {
this.splice(target, this.current()[target].length, 0, [message])
}
/**
* Prepend one message to a pending list.
* @param {'next-turn'|'next-step'} target pending list to extend
* @param {object} message message to prepend
*/
prepend(target, message) {
this.splice(target, 0, 0, [message])
}
/**
* Replace one pending message in place.
* @param {string} messageId identity of the pending message to replace
* @param {object} newMessage replacement message
* @returns {boolean} whether the message was still pending
*/
replace(messageId, newMessage) {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [newMessage])
return true
}
/**
* Remove one pending message.
* @param {string} messageId identity of the pending message to remove
* @returns {boolean} whether the message was still pending
*/
remove(messageId) {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [])
return true
}
/**
* Apply standard splice semantics and durably record the normalized result.
* @param {'next-turn'|'next-step'} target pending list to mutate
* @param {number} start splice position
* @param {number} deleteCount maximum number of messages to remove
* @param {Array<object>} inserted messages to insert at the resolved position
*/
splice(target, start, deleteCount, inserted) {
return this.mutate(target, start, deleteCount, inserted, true)
}
/** Locate one pending identity across both owned lists. */
locate(messageId) {
const state = this.current()
for (const target of ['next-turn', 'next-step']) {
const index = state[target].findIndex((message) => message.id === messageId)
if (index >= 0) return { target, index }
}
}
/** Read the current durable projection state. */
current() {
const state = this.projections.stateOf(this.session, 'inbox')
if (state === undefined) throw new Error(`agent "${this.session.id}" cannot read inbox state: its projection registration is not active`)
return state
}
/** Commit one normalized mutation and publish its live events. */
mutate(target, start, deleteCount, inserted, discardRemoved) {
const state = this.current()
const inbox = state[target]
const truncatedStart = Math.trunc(start)
const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
const actualStart = offset < 0 ? Math.max(inbox.length + offset, 0) : Math.min(offset, inbox.length)
const truncatedDeleteCount = Math.trunc(deleteCount)
const actualDeleteCount = Math.min(Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), inbox.length - actualStart)
if (actualDeleteCount === 0 && inserted.length === 0) return []
const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted)
const ids = new Set()
for (const message of target === 'next-turn' ? [...candidate, ...state['next-step']] : [...state['next-turn'], ...candidate]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined
const splice = {
target,
start: actualStart,
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
inserted,
...(outcome === undefined ? {} : { outcome }),
}
const removed = inbox.slice(actualStart, actualStart + actualDeleteCount)
const event = this.session.append('agent/inbox/spliced', splice)
if (discardRemoved) for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message })
for (const message of event.data.inserted) this.dispatch.emit('agent/inbox/inserted', { message })
return removed
}
}