refactor(agent): expose mutable inbox state
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* Concrete Agent loop over two pending-input lists: queued prompts each open a
|
||||
* turn that logs its admitted input after `turn/start` commits, while steering
|
||||
* and injected context enter through the outbox at step boundaries. Every
|
||||
* request is derived from the session log.
|
||||
*
|
||||
* Default Agent driver over queued turns and step-boundary input. Every request
|
||||
* is derived from the session log.
|
||||
* @module dsh-agent-loop/agent
|
||||
*/
|
||||
|
||||
@@ -13,9 +10,10 @@ import type {
|
||||
AgentOptions,
|
||||
AgentStatus,
|
||||
CancelOptions,
|
||||
InboxTarget,
|
||||
RequestErrorAction,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
BlockAssembler,
|
||||
@@ -27,7 +25,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { AssistantMessage, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -40,25 +38,17 @@ type Phase =
|
||||
|
||||
type Admission =
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'admitted'; claimed: UserMessage[]; messages: UserMessage[] }
|
||||
| { kind: 'admitted'; messages: UserMessage[] }
|
||||
| { kind: 'blocked' }
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
|
||||
* steps while tools or steering require another request.
|
||||
*/
|
||||
/** Drives one session through turn and step boundaries. */
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/** Prompts awaiting individual turns. */
|
||||
private queued: UserMessage[] = []
|
||||
/** Input taken into the session log at step boundaries. */
|
||||
private outbox: UserMessage[] = []
|
||||
|
||||
readonly inbox: Inbox
|
||||
private phase: Phase
|
||||
private driverDone: Promise<void> = Promise.resolve()
|
||||
|
||||
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
|
||||
readonly scope: Scope
|
||||
/** The agent's scoped composition context ({@link Agent.ctx}). */
|
||||
readonly ctx: Context
|
||||
|
||||
/** Whether this loop instance has appended its initial/resume request anchor. */
|
||||
@@ -70,13 +60,13 @@ export class ReactLoopAgent implements Agent {
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
) {
|
||||
this.inbox = new Inbox(session)
|
||||
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
|
||||
this.phase = { kind: 'idle', lastTurn }
|
||||
this.scope = createScope(loopCtx, this)
|
||||
this.ctx = this.scope.ctx.extend({ agent: this })
|
||||
}
|
||||
|
||||
/** Last activity state published to observers. */
|
||||
get status(): AgentStatus {
|
||||
return this.phase.kind === 'idle' ? 'idle' : 'running'
|
||||
}
|
||||
@@ -91,49 +81,32 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept and route one unified send item. */
|
||||
private send(message: UserMessage, target: 'next-turn' | 'next-step', wakeup: boolean): void {
|
||||
this.session.append('agent/inbox/added', message)
|
||||
private send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
|
||||
// Waking input cannot join an aborted admission or turn, so it starts the next turn.
|
||||
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
|
||||
const inbox = target === 'next-turn' || wakingAfterAbort ? this.queued : this.outbox
|
||||
inbox.push(message)
|
||||
if (wakeup) {
|
||||
this.scheduleKick()
|
||||
}
|
||||
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
|
||||
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
|
||||
if (wakeup) this.scheduleKick()
|
||||
}
|
||||
|
||||
/** Queue one ordinary prompt turn and wake the driver. */
|
||||
followup(input: UserMessage): void {
|
||||
this.send(input, 'next-turn', true)
|
||||
}
|
||||
|
||||
/** Steer the open turn, falling back to a waking prompt while idle. */
|
||||
steer(input: UserMessage): void {
|
||||
this.send(input, 'next-step', true)
|
||||
}
|
||||
|
||||
/** Append model-facing context without waking the driver. */
|
||||
inject(input: UserMessage): void {
|
||||
this.send(input, 'next-step', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending work and abort the active turn; the first cause wins.
|
||||
* The cause is signal payload for observers and the durable turn/end
|
||||
* classification — it selects no machine behavior. Teardown is just
|
||||
* `cancel({kind:'disposed'})` + driver join + {@link scope} dispose, all
|
||||
* owned by the factory.
|
||||
*/
|
||||
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
|
||||
if (!options.keepInbox) {
|
||||
for (const message of [...this.outbox.splice(0), ...this.queued.splice(0)]) {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/canceled', message)
|
||||
}
|
||||
}
|
||||
if (this.phase.kind !== 'idle') {
|
||||
this.phase.abort.abort(cause)
|
||||
this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [], 'canceled')
|
||||
this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [], 'canceled')
|
||||
}
|
||||
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
|
||||
}
|
||||
|
||||
/** Reserve a driver before deferring idle admission. */
|
||||
@@ -147,7 +120,6 @@ export class ReactLoopAgent implements Agent {
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve after the current driver and synchronous replacement chain exits. */
|
||||
async whenIdle(): Promise<void> {
|
||||
let driver: Promise<void>
|
||||
do {
|
||||
@@ -171,13 +143,12 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Claim and admit the next queued prompt, then start its turn. */
|
||||
private async admit(onTurnBoundary: boolean): Promise<Admission> {
|
||||
if (this.phase.kind !== 'running') throw new Error()
|
||||
const signal = this.phase.abort.signal
|
||||
const claimed = this.outbox.slice()
|
||||
const outboxLength = this.outbox.length
|
||||
const queued = onTurnBoundary ? this.queued[0] : undefined
|
||||
const claimed = [...this.inbox.nextStep]
|
||||
const outboxLength = this.inbox.nextStep.length
|
||||
const queued = onTurnBoundary ? this.inbox.nextTurn[0] : undefined
|
||||
if (queued !== undefined) claimed.push(queued)
|
||||
if (claimed.length === 0) return { kind: 'empty' }
|
||||
const decision = await agentEvents(this.loopCtx, this).waterfall(
|
||||
@@ -186,34 +157,31 @@ export class ReactLoopAgent implements Agent {
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (decision.kind === 'allow') {
|
||||
this.outbox.splice(0, outboxLength)
|
||||
if (queued !== undefined) this.queued.shift()
|
||||
return { kind: 'admitted', claimed, messages: decision.messages }
|
||||
} else {
|
||||
this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: decision.keepInbox })
|
||||
return { kind: 'blocked' }
|
||||
this.inbox.splice('next-step', 0, outboxLength, [], 'admitted')
|
||||
if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'admitted')
|
||||
return { kind: 'admitted', messages: decision.messages }
|
||||
}
|
||||
this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: decision.keepInbox })
|
||||
return { kind: 'blocked' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn and any request-error retry. `admitted` input enters the log
|
||||
* only after `turn/start` commits; until then it has no owner state to unwind.
|
||||
*/
|
||||
/** Admitted input stays unowned until `turn/start` commits. */
|
||||
private async turn(): Promise<boolean> {
|
||||
if (this.phase.kind === 'idle') throw new Error()
|
||||
const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController()
|
||||
const { signal } = abort
|
||||
const lastTurn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
|
||||
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
|
||||
this.setPhase(phase)
|
||||
if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0
|
||||
if (signal.aborted) return this.inbox.hasPending
|
||||
let admission: Admission
|
||||
try {
|
||||
admission = await this.admit(true)
|
||||
if (admission.kind !== 'admitted') return false
|
||||
abort.signal.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort while admission awaits
|
||||
if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0
|
||||
if (signal.aborted) return this.inbox.hasPending
|
||||
throw error
|
||||
}
|
||||
const turn = ++phase.turn
|
||||
@@ -222,14 +190,11 @@ export class ReactLoopAgent implements Agent {
|
||||
try {
|
||||
while (true) {
|
||||
if (admission.kind === 'admitted') {
|
||||
for (const message of admission.claimed) {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/admitted', message)
|
||||
}
|
||||
for (const message of admission.messages) {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
abort.signal.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
const step = ++phase.step
|
||||
this.session.append('step/start', { turn, step })
|
||||
try {
|
||||
@@ -237,34 +202,30 @@ export class ReactLoopAgent implements Agent {
|
||||
} finally {
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
abort.signal.throwIfAborted()
|
||||
if (turnEnds && this.outbox.length === 0) {
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, abort.signal)
|
||||
abort.signal.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
if (turnEnds && this.inbox.nextStep.length === 0) {
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
admission = await this.admit(false)
|
||||
if (admission.kind === 'blocked') {
|
||||
turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause }
|
||||
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
|
||||
return false
|
||||
}
|
||||
abort.signal.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
if (admission.kind === 'empty' && turnEnds) break
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort during any awaited turn operation
|
||||
if (abort.signal.aborted) turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause }
|
||||
if (signal.aborted) turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
|
||||
else turnEnds = { kind: 'error', error: errorChain(error) }
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the turn is always ended in this block
|
||||
this.session.append('turn/end', { turn, reason: turnEnds! })
|
||||
}
|
||||
return this.outbox.length > 0 || this.queued.length > 0
|
||||
return this.inbox.hasPending
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `agent/step` extension point, commit pending input, derive one
|
||||
* request, and execute its tool calls inside one durable step boundary.
|
||||
*/
|
||||
private async step(): Promise<TurnEndReason | null> {
|
||||
if (this.phase.kind !== 'running') throw new Error()
|
||||
const { turn, step, abort: { signal } } = this.phase
|
||||
@@ -275,11 +236,9 @@ export class ReactLoopAgent implements Agent {
|
||||
signal.throwIfAborted()
|
||||
const system = renderPrompt(assembly)
|
||||
|
||||
let message: AssistantMessage
|
||||
while (true) {
|
||||
const boundaryMessages = this.session.deriveMessages()
|
||||
const { request, preparedCall } = await this.buildRequest(
|
||||
turn, step, assembly.tools, system, boundaryMessages, signal,
|
||||
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
|
||||
)
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
@@ -287,8 +246,7 @@ export class ReactLoopAgent implements Agent {
|
||||
signal.throwIfAborted()
|
||||
for await (const chunk of stream) {
|
||||
signal.throwIfAborted()
|
||||
const chunkEvent = this.session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
@@ -305,47 +263,38 @@ export class ReactLoopAgent implements Agent {
|
||||
() => Promise.resolve<RequestErrorAction>(undefined),
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (action?.kind !== 'retry') {
|
||||
return { kind: 'error', error: finish.failure }
|
||||
}
|
||||
} else {
|
||||
message = createAssistantMessage({
|
||||
content: assembler.blocks(),
|
||||
source: {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
||||
},
|
||||
})
|
||||
this.session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
message,
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
if (finish.kind === 'max-tokens') {
|
||||
return { kind: 'max-tokens' }
|
||||
}
|
||||
break
|
||||
if (action?.kind !== 'retry') return { kind: 'error', error: finish.failure }
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
let result: TurnEndReason | null
|
||||
if (toolCalls.length > 0) {
|
||||
const message = createAssistantMessage({
|
||||
content: assembler.blocks(),
|
||||
source: {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
||||
},
|
||||
})
|
||||
this.session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
message,
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
|
||||
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
if (toolCalls.length === 0) return { kind: 'completed' }
|
||||
const { concluded } = await executeToolCalls(
|
||||
this.loopCtx, turn, step, toolCalls, signal,
|
||||
context => this.outbox.push(context),
|
||||
context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
|
||||
)
|
||||
result = concluded ? { kind: 'completed' } : null
|
||||
} else {
|
||||
result = { kind: 'completed' }
|
||||
return concluded ? { kind: 'completed' } : null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,8 +309,6 @@ export class ReactLoopAgent implements Agent {
|
||||
boundaryMessages: Message[],
|
||||
signal: AbortSignal,
|
||||
): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
|
||||
// A loop instance starts from its declared route, restoring only an opaque
|
||||
// effort owned by that exact model. Later steps fold the config it logged.
|
||||
const persistedConfig = this.session.requestHeader()?.config
|
||||
const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
|
||||
const reasoningEffort = persistedConfig?.provider === route.provider
|
||||
@@ -369,16 +316,14 @@ export class ReactLoopAgent implements Agent {
|
||||
? persistedConfig.reasoningEffort
|
||||
: undefined
|
||||
const maxTokens = this.options.maxTokens
|
||||
const seedConfig = deepFreeze(structuredClone(
|
||||
this.requestHeaderLogged
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds
|
||||
? persistedConfig!
|
||||
: {
|
||||
...route,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
},
|
||||
))
|
||||
const seedConfig = this.requestHeaderLogged
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the frozen header it now folds
|
||||
? persistedConfig!
|
||||
: deepFreeze({
|
||||
...route,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
})
|
||||
const proposedConfig = await this.loopCtx.waterfall(
|
||||
agentCarrier(this), 'agent/request', this, turn, step, signal,
|
||||
() => Promise.resolve(seedConfig),
|
||||
@@ -393,8 +338,7 @@ export class ReactLoopAgent implements Agent {
|
||||
preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
|
||||
config = preparedCall.config
|
||||
} catch (error: unknown) {
|
||||
// A llm/stream listener may own and short-circuit a route with no
|
||||
// adapter. Terminal dispatch still raises NO_ADAPTER when none does.
|
||||
// Middleware may serve an unregistered route; terminal dispatch still requires an adapter.
|
||||
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
|
||||
config = proposedConfig
|
||||
}
|
||||
|
||||
@@ -76,8 +76,6 @@ describe('Agent.cancel()', () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const canceled: unknown[] = []
|
||||
ctx.on('agent/inbox/canceled', (subject, message) => { if (subject === agent) canceled.push(message) })
|
||||
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserved' }],
|
||||
@@ -85,7 +83,8 @@ describe('Agent.cancel()', () => {
|
||||
}))
|
||||
// Abort the collecting activity while preserving its queued item.
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
expect(canceled).toEqual([])
|
||||
expect(agent.session.events.some(event =>
|
||||
event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false)
|
||||
|
||||
// The preserved item still runs once a later follow-up wakes the driver.
|
||||
send(agent, 'wake it')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { createUserMessage, freezeMessage, CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { ReactLoopAgent } from '../src/agent.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -53,8 +53,8 @@ function send(agent: Agent, text: string) {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
}
|
||||
|
||||
function inboxText(item: InboxItem): string {
|
||||
return item.message.content
|
||||
function inboxText(message: UserMessage): string {
|
||||
return message.content
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
.join('')
|
||||
}
|
||||
@@ -69,42 +69,28 @@ describe('addressable inbox operations', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
const admission = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/prompt-submit', async (_subject, message, _signal, next) => {
|
||||
if (message.content[0]?.type === 'text' && message.content[0].text === 'first') {
|
||||
ctx.on('agent/prompt-submit', async (_subject, messages, _signal, next) => {
|
||||
if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') {
|
||||
admission.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
const pending: InboxItem[] = []
|
||||
const updates: { id: string; text: string }[] = []
|
||||
const discards: string[][] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent && inboxText(item) !== 'first') pending.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/update', (subject, item) => {
|
||||
if (subject === agent) updates.push({ id: item.id, text: inboxText(item) })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discards.push(items.map(item => item.id))
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await admission.promise
|
||||
send(agent, 'remove me')
|
||||
send(agent, 'edit me')
|
||||
const pending = agent.inbox.nextTurn
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
|
||||
|
||||
const remove = pending[0]!
|
||||
const edit = pending[1]!
|
||||
expect(agent.updateInbox(edit.id, {
|
||||
kind: 'edit',
|
||||
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
|
||||
...edit,
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})).toBe('applied')
|
||||
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
|
||||
expect(updates).toEqual([{ id: edit.id, text: 'edited' }])
|
||||
expect(discards).toEqual([[remove.id]])
|
||||
})])).toEqual([edit])
|
||||
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
release.resolve(undefined)
|
||||
@@ -115,46 +101,7 @@ describe('addressable inbox operations', () => {
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
: ''))
|
||||
.toEqual(['first', 'edited'])
|
||||
expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found')
|
||||
})
|
||||
|
||||
it('does not mutate steering occurrences', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const pending: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent && item.placement === 'steering') pending.push(item)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'admitted prompt')
|
||||
await entered.promise
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } }))
|
||||
expect(pending.map(inboxText)).toEqual(['keep me'])
|
||||
|
||||
const steering = pending[0]!
|
||||
expect(agent.updateInbox(steering.id, {
|
||||
kind: 'edit',
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})).toBe('not-found')
|
||||
expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found')
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'steering/message')
|
||||
.map(event => event.type === 'steering/message'
|
||||
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
: ''))
|
||||
.toEqual(['keep me'])
|
||||
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -590,7 +537,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('agent/inbox/enqueue carries the exact message; steering/message records its source', async () => {
|
||||
it('durable inbox splices carry exact messages and steering/message preserves its source', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -604,27 +551,30 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
const queuedSources: MessageSource[] = []
|
||||
const queuedShapes: string[][] = []
|
||||
const placements: InboxPlacement[] = []
|
||||
ctx.on('agent/inbox/enqueue', (_agent, item) => {
|
||||
queuedSources.push(item.message.source)
|
||||
queuedShapes.push(Object.keys(item.message).sort())
|
||||
placements.push(item.placement)
|
||||
const insertedSources: MessageSource[] = []
|
||||
const insertedShapes: string[][] = []
|
||||
const targets: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'agent/inbox/spliced') return
|
||||
for (const message of event.data.inserted) {
|
||||
insertedSources.push(message.source)
|
||||
insertedShapes.push(Object.keys(message).sort())
|
||||
targets.push(event.data.target)
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(queuedSources).toEqual([
|
||||
expect(insertedSources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'goal' },
|
||||
])
|
||||
expect(queuedShapes).toEqual([
|
||||
expect(insertedShapes).toEqual([
|
||||
['content', 'id', 'role', 'source'],
|
||||
['content', 'id', 'role', 'source'],
|
||||
])
|
||||
expect(placements).toEqual(['queued', 'steering'])
|
||||
expect(targets).toEqual(['next-turn', 'next-step'])
|
||||
// The drain appends the durable steering/message with the caller's source
|
||||
// intact — the log, not a transient emit, is where consumers read it.
|
||||
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.message.source] : [])
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
109
packages/core/agent/src/inbox.ts
Normal file
109
packages/core/agent/src/inbox.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
|
||||
@@ -8,14 +8,9 @@
|
||||
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
|
||||
|
||||
const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
|
||||
'agent/cancel-requested': args => args[0],
|
||||
'agent/created': args => args[0],
|
||||
'agent/disposed': args => args[0],
|
||||
'agent/error': args => args[0],
|
||||
'agent/inbox/dequeue': args => args[0],
|
||||
'agent/inbox/discard': args => args[0],
|
||||
'agent/inbox/enqueue': args => args[0],
|
||||
'agent/inbox/update': args => args[0],
|
||||
'agent/prompt-submit': args => args[0],
|
||||
'agent/request': args => args[0],
|
||||
'agent/request-error': args => args[0],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { InboxItemId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -44,27 +44,22 @@ describe('scoped-dispatch invariants', () => {
|
||||
content: [],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const item = { id: InboxItemId('i'), message, placement: 'queued' as const }
|
||||
const agentRows = {
|
||||
'agent/created': [agent],
|
||||
'agent/disposed': [agent],
|
||||
'agent/status': [agent, 'idle'],
|
||||
'agent/inbox/enqueue': [agent, item],
|
||||
'agent/inbox/update': [agent, item],
|
||||
'agent/inbox/dequeue': [agent, item],
|
||||
'agent/inbox/discard': [agent, []],
|
||||
'agent/session-start': [agent, 'startup'],
|
||||
'agent/step': [agent, 1, 1, signal],
|
||||
'agent/prompt-submit': [agent, message, signal, () => Promise.resolve({ kind: 'allow' })],
|
||||
'agent/prompt-submit': [agent, [message], signal, () => Promise.resolve({ kind: 'allow', messages: [message] })],
|
||||
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)],
|
||||
'agent/request-error': [
|
||||
agent,
|
||||
1,
|
||||
1,
|
||||
new Error('request'),
|
||||
{ message: 'request', code: 'UNKNOWN' },
|
||||
[],
|
||||
undefined,
|
||||
{
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'p',
|
||||
failure: { message: 'request', code: 'UNKNOWN' },
|
||||
},
|
||||
signal,
|
||||
() => Promise.resolve(undefined),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user