feat(agent): unify send(target × wakeup), coalesce context/message into user/message

Replace send/steer/inject with one Agent.send primitive over the
(target × wakeup) matrix; followup/steer/inject become fixed-preset
alias methods on the now-abstract Agent class. Coalesce context/message
into user/message (injected context is a non-user source). Replace
agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel
keepInbox, and add a FIFO-conservation invariant.
This commit is contained in:
Turtle
2026-07-23 19:15:45 +08:00
parent 7c0c516f60
commit 44fd93fd06
117 changed files with 1249 additions and 728 deletions

View File

@@ -8,8 +8,8 @@
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Agent } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, InboxItemInfo, SendOptions } from '@deepseek-ai/dsh-agent'
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
@@ -100,7 +100,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context):
* the loop driver. Everything observable happens through session events and
* the agent/* event taxonomy — plugins never need this class.
*/
export class ReactLoopAgent implements Agent {
export class ReactLoopAgent extends Agent {
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
readonly #inbox = new Inbox()
@@ -161,6 +161,7 @@ export class ReactLoopAgent implements Agent {
public readonly session: Session,
maxParallelToolCalls: number,
) {
super()
this.maxParallelToolCalls = maxParallelToolCalls
const { promise, resolve } = Promise.withResolvers<void>()
this.disposed = promise
@@ -190,25 +191,25 @@ export class ReactLoopAgent implements Agent {
for (const resolve of waiters) resolve()
}
private resolveSource(options?: SendOptions): MessageSource {
return options?.source ?? { kind: 'user' }
}
/**
* Accept one public message payload as a detached record. Lossless-JSON
* materialization reads every nested field once; deep freeze prevents later
* caller mutation before an inbox or deferred-injection queue drains it.
*/
private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
const source = this.resolveSource(options)
private acceptMessage(content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage {
const contexts = options?.contexts ?? []
const accepted = snapshotJsonValue({ content, source, contexts })
const accepted = snapshotJsonValue({ content, source, contexts, wakeup })
if (accepted === undefined) {
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
}
return deepFreeze(accepted)
}
/** Build the `agent/inbox/*` payload for one accepted item. */
private inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo {
return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
}
/** Detach one context before it can outlive its caller in the active-batch FIFO. */
private acceptContext(context: HookContext): HookContext {
const accepted = snapshotJsonValue(context)
@@ -225,24 +226,26 @@ export class ReactLoopAgent implements Agent {
send(content: ContentBlock[], options?: SendOptions): void {
this.assertNotDisposed()
const accepted = this.acceptMessage(content, options)
this.#inbox.enqueue(accepted)
const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
const target = options?.target ?? 'next-turn'
const wakeup = options?.wakeup ?? true
// next-step/no-wakeup is injection: durable context without running the model.
if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return }
// next-step/wakeup is steering into the running turn; idle falls back to a
// woken follow-up turn (there is no active turn to attach to).
const steering = target === 'next-step' && this._status === 'running'
const source = options?.source ?? { kind: 'user' }
const accepted = this.acceptMessage(content, source, wakeup, options)
if (steering) {
this.#inbox.steer(accepted)
} else {
this.#inbox.enqueue(accepted, wakeup)
}
agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', this.inboxInfo(accepted, steering))
}
steer(content: ContentBlock[], options?: SendOptions): void {
this.assertNotDisposed()
if (this._status !== 'running') { this.send(content, options); return }
const accepted = this.acceptMessage(content, options)
this.#inbox.steer(accepted)
const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
}
inject(content: ContentBlock[], options?: InjectOptions): void {
this.assertNotDisposed()
const source = this.resolveSource(options)
/** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */
private injectContext(content: ContentBlock[], options?: SendOptions): void {
const source = options?.source ?? { kind: 'plugin', plugin: '' }
const context = {
content,
source,
@@ -257,7 +260,7 @@ export class ReactLoopAgent implements Agent {
this.deferredInjections.push(accepted)
return
}
this.session.append('context/message', accepted, { surfaceOp: 'append' })
this.session.append('user/message', accepted, { surfaceOp: 'append' })
return
}
// No turn open: wrap the injection in a one-shot turn so every event stays
@@ -269,7 +272,7 @@ export class ReactLoopAgent implements Agent {
// are contained by Session and cannot create a false append failure.
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', context, { surfaceOp: 'append' })
this.session.append('user/message', context, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log. A pre-commit veto
// must escape rather than being mistaken for a committed turn/end.
@@ -301,7 +304,7 @@ export class ReactLoopAgent implements Agent {
private drainDeferredInjections(): void {
const pending = this.deferredInjections.splice(0)
for (const accepted of pending) {
this.session.append('context/message', accepted, { surfaceOp: 'append' })
this.session.append('user/message', accepted, { surfaceOp: 'append' })
}
}
@@ -325,10 +328,14 @@ export class ReactLoopAgent implements Agent {
}
}
cancel(cause?: AgentCancelCause): void {
cancel(cause?: AgentCancelCause, options?: CancelOptions): void {
const resolvedCause = cause ?? { kind: 'user' }
const keepInbox = options?.keepInbox ?? false
const cancellation = this.turnCancellation
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
// keepInbox preserves pending work, so un-started items must not arm the
// pre-run cancel path that would otherwise drop the next queued turn.
const preRun = !keepInbox && cancellation === undefined
&& (this.#inbox.hasQueued || this.#inbox.hasSteering)
if (cancellation !== undefined || preRun) {
if (preRun) this.preRunCancelled = true
// Coordination consumers must update their own state before this call
@@ -336,9 +343,18 @@ export class ReactLoopAgent implements Agent {
// contained by the fused dispatcher and cannot veto cancellation.
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
}
// Clear work already present before abort observers run. A replacement
// synchronously enqueued by an observer belongs to the next turn.
this.#inbox.clear()
if (!keepInbox) {
// Snapshot before clearing so the discard notification carries the exact
// dropped items; a replacement synchronously enqueued by an
// `agent/cancel-requested` observer belongs to the next turn, not here.
const discarded = this.#inbox.pending()
// Clear work already present before abort observers run.
this.#inbox.clear()
if (discarded.length > 0) {
const items = discarded.map(({ message, steering }) => this.inboxInfo(message, steering))
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
}
}
cancellation?.request(resolvedCause)
}

View File

@@ -1,7 +1,7 @@
/**
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
* mechanism of the loop driver — the public surface is `Agent.send()` and
* `Agent.steer()`.
* mechanism of the loop driver — the public surface is `Agent.send()` and its
* fixed-preset aliases.
*
* @module dsh-agent-loop/inbox
*/
@@ -14,12 +14,14 @@ export interface InboxMessage {
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
/** Whether the item is marked to wake the driver or force a continuation. */
wakeup: boolean
}
/**
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
* (drained between steps of a running turn). Purely an in-memory mechanism of
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
* the loop — the public surface is `Agent.send()` and its aliases.
*/
export class Inbox {
private queuedMessages: InboxMessage[] = []
@@ -37,18 +39,21 @@ export class Inbox {
}
/**
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
* Add a message to the queued FIFO, waking a parked {@link waitForQueued}
* unless the item opted out. A non-waking item still runs once any woken
* item or later wakeup drives the parked loop.
* @param message - the message to queue for the next turn start.
* @param wake - whether to wake a parked idle wait (default true).
*/
enqueue(message: InboxMessage): void {
enqueue(message: InboxMessage, wake = true): void {
this.queuedMessages.push(message)
this.wakeup?.()
if (wake) this.wakeup?.()
}
/**
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
* drained between steps of a running turn, never by the idle wait —
* `Agent.steer()` on an idle agent falls back to `send()` instead.
* `Agent.steer()` on an idle agent falls back to a woken follow-up instead.
* @param message - the message to inject between steps of the running turn.
*/
steer(message: InboxMessage): void {
@@ -71,6 +76,18 @@ export class Inbox {
return this.steeringMessages.splice(0)
}
/**
* Snapshot the pending items (queued then steering, FIFO order) without
* removing them — the discard notification's payload source.
* @returns the pending items paired with whether each is steering.
*/
pending(): { message: InboxMessage; steering: boolean }[] {
return [
...this.queuedMessages.map(message => ({ message, steering: false })),
...this.steeringMessages.map(message => ({ message, steering: true })),
]
}
/**
* Discard all pending messages (queued + steering) without delivering them —
* used by `cancel()`, which drops un-started work rather than draining it into

View File

@@ -10,7 +10,7 @@ import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFai
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, InboxItemInfo, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
@@ -19,9 +19,14 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
import type { Inbox } from './inbox.ts'
import type { Inbox, InboxMessage } from './inbox.ts'
import type { TurnCancellation } from './cancellation.ts'
/** Build the `agent/inbox/dequeue` payload for one claimed inbox item. */
function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo {
return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
}
/** Normalize thrown values while preserving an existing error code. */
function toError(error: unknown): RequestError {
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
@@ -279,10 +284,11 @@ async function runTurn(
const drainSteering = (): boolean => {
const messages = handle.inbox.drainSteering()
for (const message of messages) {
events.emit('agent/inbox/dequeue', inboxInfo(message, true))
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
session.append('context/message', {
session.append('user/message', {
content: context.content,
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
@@ -296,6 +302,7 @@ async function runTurn(
const message = handle.inbox.dequeueQueued()
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
events.emit('agent/inbox/dequeue', inboxInfo(message, false))
const trigger: TurnTrigger = { kind: 'message', source: message.source }
let reason: TurnEndReason = { kind: 'completed' }
@@ -538,7 +545,7 @@ async function runTurn(
// A continuation reason becomes next-step steering.
if (decision.action === 'continue' && decision.reason) {
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] })
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true })
}
let shouldContinue = decision.action === 'continue'