refactor(agent): expose mutable inbox state

This commit is contained in:
_Kerman
2026-07-30 15:35:18 +08:00
parent c0ef93efc8
commit 4370004360
52 changed files with 534 additions and 913 deletions

View File

@@ -15,10 +15,6 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./brand": {
"types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -32,7 +28,6 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
@@ -41,7 +36,6 @@
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",

View File

@@ -1,23 +0,0 @@
/**
* dsh-agent's owned branded ids for live inbox occurrences.
*
* @module @deepseek-ai/dsh-agent/brand
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Identifies one accepted occurrence in an agent inbox. Re-sending the same
* message creates a distinct item id, so pending work remains independently
* addressable.
*/
export type InboxItemId = Branded<'InboxItemId'>
/**
* Brand a string as an {@link InboxItemId}.
* @param id - the agent-loop-minted occurrence identifier.
* @returns the same string, branded; no validation is performed.
*/
export function InboxItemId(id: string): InboxItemId {
return id as InboxItemId
}

View File

@@ -0,0 +1,109 @@
/**
* Incremental projection of durable agent inbox events.
*
* @module @deepseek-ai/dsh-agent/inbox
*/
import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
/** One of the two ordered pending-message lists owned by an agent. */
export type InboxTarget = 'next-turn' | 'next-step'
/** Mutable state privately owned by an {@link Inbox}. */
type InboxState = Record<InboxTarget, UserMessage[]>
/** A replay-once projection that incrementally consumes later inbox splices. */
export class Inbox {
private readonly state: InboxState = { 'next-turn': [], 'next-step': [] }
constructor(private readonly session: Session) {
for (const event of session.events.slice(session.header.seedLength ?? 0)) {
if (event.type !== 'agent/inbox/spliced') continue
try {
this.apply(event.data)
} catch (error: unknown) {
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
}
}
}
/** Prompts awaiting individual turns. */
get nextTurn(): readonly UserMessage[] {
return this.state['next-turn']
}
/** Input awaiting admission at a step boundary. */
get nextStep(): readonly UserMessage[] {
return this.state['next-step']
}
/** Whether either pending-message list contains work. */
get hasPending(): boolean {
return this.nextTurn.length > 0 || this.nextStep.length > 0
}
/**
* Apply standard splice semantics and durably record the normalized result.
* @param target - pending list to mutate.
* @param start - splice position.
* @param deleteCount - maximum number of messages to remove.
* @param inserted - messages to insert at the resolved position.
* @param outcome - terminal disposition of removed messages.
* @returns messages removed by the splice.
*/
splice(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
outcome?: 'admitted' | 'canceled',
): UserMessage[] {
const inbox = this.state[target]
const offset = Math.trunc(start) || 0
const actualStart = offset < 0
? Math.max(inbox.length + offset, 0)
: Math.min(offset, inbox.length)
const actualDeleteCount = Math.min(
Math.max(Math.trunc(deleteCount) || 0, 0),
inbox.length - actualStart,
)
if (actualDeleteCount === 0 && inserted.length === 0) return []
const resolvedOutcome = outcome ?? (actualDeleteCount > 0 ? 'canceled' : undefined)
const splice = {
target,
start: actualStart,
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
inserted,
...(resolvedOutcome === undefined ? {} : { outcome: resolvedOutcome }),
}
this.validate(splice)
const event = this.session.append('agent/inbox/spliced', splice)
return inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted)
}
/** Apply one normalized durable splice to the projection. */
private apply(splice: SessionEventMap['agent/inbox/spliced']): UserMessage[] {
this.validate(splice)
const inbox = this.state[splice.target]
return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
}
/** Validate one normalized splice against the current projection. */
private validate(splice: SessionEventMap['agent/inbox/spliced']): void {
const inbox = this.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 candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
const ids = new Set<string>()
for (const message of splice.target === 'next-turn'
? [...candidate, ...this.nextStep]
: [...this.nextTurn, ...candidate]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
}
}

View File

@@ -15,7 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export * from './brand.ts'
export * from './inbox.ts'
export * from './llm-target.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'

View File

@@ -7,10 +7,10 @@
import type { Context } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
import type { InboxItemId } from './brand.ts'
import type { Inbox, InboxTarget } from './inbox.ts'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
@@ -29,63 +29,12 @@ export interface AgentOptions {
maxTokens?: number
}
/**
* Which inbox queue a {@link Agent.send} item joins:
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
* - `next-step` — during prompt admission or an open turn, the item stages for
* the next safe step boundary; otherwise it is promoted per its `wakeup`
* flag.
*/
export type SendTarget = 'next-turn' | 'next-step'
/** Resolved inbox placement reported when an accepted message is enqueued. */
export type InboxPlacement = 'queued' | 'steering'
/** One independently addressable accepted occurrence in an agent inbox. */
export interface InboxItem {
/** Agent-loop-minted occurrence identity. */
readonly id: InboxItemId
/** Identified message delivered by the caller. */
readonly message: UserMessage
/** Acceptance-time FIFO classification. */
readonly placement: InboxPlacement
}
/** A user-requested mutation of one still-pending queued occurrence. */
export type InboxAction =
| { readonly kind: 'edit'; readonly content: ContentBlock[] }
| { readonly kind: 'remove' }
/** Result of applying an inbox action at the synchronous ownership boundary. */
export type InboxActionResult = 'applied' | 'not-found'
/**
* Options for the unified {@link Agent.send} primitive over the
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
* {@link Agent.inject} (`next-step`/no-wakeup).
*
* The object is complete so routing policy is explicit.
*/
export interface SendOptions {
/** Queue the item joins. */
target: SendTarget
/**
* Whether this item makes the model run: wake a parked driver (`next-turn`)
* or force a continuation step (`next-step` while running). A `false`
* `next-turn` item queues without waking; a `false`
* `next-step` item attaches durable context without forcing another step
* (the injection preset).
*/
wakeup: boolean
}
/** Options for {@link Agent.cancel}. */
export interface CancelOptions {
/**
* Preserve queued and steering inbox items instead of discarding them. The
* active turn is still aborted, but un-started and pending work survives for a
* later turn and no `agent/inbox/canceled` fires.
* later turn and no canceled inbox splice is logged.
*/
keepInbox?: boolean | undefined
}
@@ -136,30 +85,13 @@ export interface Agent {
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The agent-owned projection of durable pending work. */
readonly inbox: Inbox
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
/** Whether a next-step send currently remains in the open turn. */
readonly acceptsNextStep: boolean
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
readonly ctx: Context
/**
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
* @param message - identified model-facing content and its producer provenance.
* @param options - target queue and wakeup decision.
*/
send(message: UserMessage, options: SendOptions): void
/**
* Mutate one still-pending queued occurrence synchronously. Editing preserves
* the message identity and queue position; removal publishes its terminal
* discard. Steering occurrences and driver-claimed items return `not-found`.
* @param id - independently addressable queued occurrence.
* @param action - edit or remove operation.
* @returns whether the pending occurrence was found and updated.
*/
updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
* turn. The first cause wins for the active turn. Idle cancellation is a
@@ -236,48 +168,6 @@ declare module 'cordis' {
* @mode emit
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* An item entered the queued or steering inbox. `placement` is the
* acceptance-time routing result.
* @param agent - the owning agent.
* @param item - accepted occurrence, message, and resolved placement.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
/**
* A still-pending queued item changed content.
* @param agent - the owning agent.
* @param item - the complete post-update occurrence.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/update'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
/**
* The driver claimed one item out of the inbox.
* @param agent - the agent whose inbox item was claimed.
* @param item - the exact claimed occurrence.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
/**
* Pending inbox items were dropped without delivery.
* @param agent - the agent whose inbox items were dropped.
* @param items - the discarded occurrences in FIFO order.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItem[]): void
/**
* Effective broad cancellation was requested before pending work clears or
* the active turn aborts.
* @param agent - the agent whose current work is being cancelled.
* @param cause - the explicit typed cancellation cause.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void
// ---- session lifecycle (emit) ----
/**
* The session lifecycle began, once before the first turn. Use
@@ -374,7 +264,13 @@ declare module 'cordis' {
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** One message was accepted into the agent inbox. */
'agent/inbox/added': UserMessage
/** One normalized mutation of an agent's durable pending-message lists. */
'agent/inbox/spliced': {
target: InboxTarget
start: number
removedCount?: number
inserted: UserMessage[]
outcome?: 'admitted' | 'canceled'
}
}
}

View File

@@ -21,8 +21,6 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
session: new Session(id),
status: 'idle',
ctx: new Context(),
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject: () => {},

View File

@@ -1,7 +1,6 @@
import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { InboxItemId, type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -44,51 +43,3 @@ describe('agent status invariants', () => {
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
})
})
describe('agent inbox invariants', () => {
let nextItem = 0
const info = (placement: InboxPlacement = 'queued'): InboxItem => ({
id: InboxItemId(`i-${nextItem++}`),
message: freezeMessage({
id: MessageId('m'),
role: 'user' as const,
content: [],
source: { kind: 'user' as const },
}),
placement,
})
it('accepts a dequeue and a discard covered by prior enqueues', async () => {
const ctx = await setup()
const agent = mockAgent('i1')
const at = scopeTarget(agent, agent)
expect(() => {
ctx.emit(at, 'agent/inbox/enqueue', agent, info())
ctx.emit(at, 'agent/inbox/enqueue', agent, info('steering'))
ctx.emit(at, 'agent/inbox/dequeue', agent, info())
ctx.emit(at, 'agent/inbox/discard', agent, [info()])
}).not.toThrow()
})
it('rejects a dequeue with no outstanding item', async () => {
const ctx = await setup()
const agent = mockAgent('i2')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) })
.toThrow(/without a matching prior enqueue/)
})
it('rejects a discard larger than the outstanding count', async () => {
const ctx = await setup()
const agent = mockAgent('i3')
const at = scopeTarget(agent, agent)
ctx.emit(at, 'agent/inbox/enqueue', agent, info())
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) })
.toThrow(/dropped 2 items but only 1 were outstanding/)
})
it('accepts an empty discard against a fresh agent', async () => {
const ctx = await setup()
const agent = mockAgent('i4')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow()
})
})

View File

@@ -14,9 +14,6 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../core/scope"
},