refactor(agent): name delivery methods by intent

This commit is contained in:
Tianyi Cui
2026-07-24 12:27:20 +08:00
parent 7b7f793ee5
commit 086e454931
39 changed files with 514 additions and 484 deletions
+79 -20
View File
@@ -9,11 +9,19 @@
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
import { Agent } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent'
import type {
Agent,
AgentCancelCause,
AgentOptions,
AgentStatus,
CancelOptions,
HookContext,
InjectOptions,
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'
import { snapshotJsonValue, type JsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts'
import { Inbox, agentMessage, type InboxMessage } from './inbox.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
@@ -33,6 +41,17 @@ const bindContext = Symbol('dsh.agent-loop.bind-context')
/** Module-private publication marker. */
const publishAgent = Symbol('dsh.agent-loop.publish-agent')
/** Fully resolved input accepted only by the concrete driver's private delivery mechanism. */
type ResolvedDelivery = {
content: ContentBlock[]
source: MessageSource
meta: JsonValue | undefined
} & (
| { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] }
| { target: 'next-step'; wakeup: true; contexts: HookContext[] }
| { target: 'next-step'; wakeup: false; contexts: [] }
)
/** Factory-owned controls that can operate only on the agent created with them. */
export interface PreparedReactLoopAgent {
/** The unpublished concrete agent. */
@@ -101,7 +120,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 extends Agent {
export class ReactLoopAgent implements Agent {
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
readonly #inbox = new Inbox()
@@ -162,7 +181,6 @@ export class ReactLoopAgent extends Agent {
public readonly session: Session,
maxParallelToolCalls: number,
) {
super()
this.maxParallelToolCalls = maxParallelToolCalls
const { promise, resolve } = Promise.withResolvers<void>()
this.disposed = promise
@@ -197,13 +215,11 @@ export class ReactLoopAgent extends Agent {
* materialization reads every nested field once; deep freeze prevents later
* caller mutation before an inbox or deferred-injection queue drains it.
*/
private acceptMessage(
id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions,
): InboxMessage {
const contexts = options?.contexts ?? []
private snapshotMessage(id: AgentMessageId, delivery: ResolvedDelivery): InboxMessage {
const { content, source, contexts, wakeup, meta } = delivery
const accepted = snapshotJsonValue({
id, content, source, contexts, wakeup,
...options?.meta !== undefined ? { meta: options.meta } : {},
...meta !== undefined ? { meta } : {},
})
if (accepted === undefined) {
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
@@ -225,18 +241,17 @@ export class ReactLoopAgent extends Agent {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
}
send(content: ContentBlock[], options?: SendOptions): AgentMessageId {
/** Accept one fully resolved intent through the concrete driver's private routing matrix. */
#acceptDelivery(delivery: ResolvedDelivery): AgentMessageId {
this.assertNotDisposed()
const id = AgentMessageId(randomUUID())
const target = options?.target ?? 'next-turn'
const wakeup = options?.wakeup ?? true
const { target, wakeup } = delivery
// next-step/no-wakeup is injection: durable context without running the model.
if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return id }
if (target === 'next-step' && !wakeup) { this.injectContext(delivery); return id }
// 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).
// waking ordinary 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(id, content, source, wakeup, options)
const accepted = this.snapshotMessage(id, delivery)
if (steering) {
this.#inbox.steer(accepted)
} else {
@@ -246,13 +261,57 @@ export class ReactLoopAgent extends Agent {
return id
}
send(content: ContentBlock[], options?: SendOptions): AgentMessageId {
return this.#acceptDelivery({
content,
target: 'next-turn',
wakeup: true,
source: options?.source ?? { kind: 'user' },
contexts: options?.contexts ?? [],
meta: options?.meta,
})
}
queue(content: ContentBlock[], options?: SendOptions): AgentMessageId {
return this.#acceptDelivery({
content,
target: 'next-turn',
wakeup: false,
source: options?.source ?? { kind: 'user' },
contexts: options?.contexts ?? [],
meta: options?.meta,
})
}
steer(content: ContentBlock[], options?: SendOptions): AgentMessageId {
return this.#acceptDelivery({
content,
target: 'next-step',
wakeup: true,
source: options?.source ?? { kind: 'user' },
contexts: options?.contexts ?? [],
meta: options?.meta,
})
}
inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId {
return this.#acceptDelivery({
content,
target: 'next-step',
wakeup: false,
source: options?.source ?? { kind: 'plugin', plugin: '' },
contexts: [],
meta: options?.meta,
})
}
/** 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: '' }
private injectContext(delivery: Extract<ResolvedDelivery, { target: 'next-step'; wakeup: false }>): void {
const { content, source, meta } = delivery
const context = {
content,
source,
...options?.meta !== undefined ? { meta: options.meta } : {},
...meta !== undefined ? { meta } : {},
}
if (isTurnOpen(this.session)) {
const accepted = this.acceptContext(context)
+5 -5
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 its
* fixed-preset aliases.
* mechanism of the loop driver — callers use `Agent`'s intent-named delivery
* methods instead.
*
* @module dsh-agent-loop/inbox
*/
@@ -10,7 +10,7 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent'
/** One message waiting in an agent's inbox; `id` is the value `send` returned. */
/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */
export interface InboxMessage {
id: AgentMessageId
content: ContentBlock[]
@@ -35,7 +35,7 @@ export function agentMessage(message: InboxMessage, steering: boolean): AgentMes
/**
* 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()` and its aliases.
* the loop — the public surface is `Agent`'s intent-named delivery methods.
*/
export class Inbox {
private queuedMessages: InboxMessage[] = []
@@ -78,7 +78,7 @@ export class Inbox {
/**
* 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 a woken follow-up instead.
* `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead.
* @param message - the message to inject between steps of the running turn.
*/
steer(message: InboxMessage): void {