refactor(agent-loop): simplify message machine
This commit is contained in:
25 files changed
+1205
-2622
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -1,31 +0,0 @@
|
||||
/** Turn-scoped cancellation ownership for the concrete AgentLoop driver. @module dsh-agent-loop/cancellation */
|
||||
|
||||
import type { AgentCancelCause } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */
|
||||
export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const)
|
||||
|
||||
/**
|
||||
* Owns the single controller shared by every asynchronous boundary of one turn.
|
||||
* The first request wins because a later caller must not rewrite the cause
|
||||
* observed by earlier listeners.
|
||||
*/
|
||||
export class TurnCancellation {
|
||||
readonly #controller = new AbortController()
|
||||
|
||||
/** The explicit signal passed through this turn's execution boundaries. */
|
||||
get signal(): AbortSignal {
|
||||
return this.#controller.signal
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the turn once.
|
||||
* @param reason - a typed caller cause or lifecycle disposal marker.
|
||||
* @returns whether this request established the signal reason.
|
||||
*/
|
||||
request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean {
|
||||
if (this.signal.aborted) return false
|
||||
this.#controller.abort(Object.freeze({ kind: reason.kind }))
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module dsh-agent-loop/inbox
|
||||
*/
|
||||
|
||||
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. */
|
||||
export interface InboxMessage {
|
||||
id: AgentMessageId
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
contexts: HookContext[]
|
||||
/** Whether the item is marked to wake the driver or force a continuation. */
|
||||
wakeup: boolean
|
||||
/** Opaque durable JSON state retained on the durable message but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `agent/inbox/*` event payload for one inbox item.
|
||||
* @param message - the accepted inbox record.
|
||||
* @param steering - whether the item is in the steering FIFO (`next-step`).
|
||||
* @returns the live-event message for enqueue/dequeue/discard.
|
||||
*/
|
||||
export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage {
|
||||
return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class Inbox {
|
||||
private queuedMessages: InboxMessage[] = []
|
||||
private steeringMessages: InboxMessage[] = []
|
||||
private wakeup: (() => void) | undefined
|
||||
|
||||
/** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */
|
||||
get hasQueued(): boolean {
|
||||
return this.queuedMessages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a queued message wants to wake the driver — the "should the loop
|
||||
* run" signal read by the idle wait's fast path, the loop's idle-publish
|
||||
* check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this
|
||||
* false, so the driver stays parked until a waking send (or a waking item
|
||||
* ahead of it in FIFO order) drives the loop; the quiet item then rides along.
|
||||
*/
|
||||
get hasWakingQueued(): boolean {
|
||||
return this.queuedMessages.some(message => message.wakeup)
|
||||
}
|
||||
|
||||
/** True while steering messages are pending — read by cancellation and the loop's stop-override check. */
|
||||
get hasSteering(): boolean {
|
||||
return this.steeringMessages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, wake = true): void {
|
||||
this.queuedMessages.push(message)
|
||||
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 a woken follow-up instead.
|
||||
* @param message - the message to inject between steps of the running turn.
|
||||
*/
|
||||
steer(message: InboxMessage): void {
|
||||
this.steeringMessages.push(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the oldest queued message for one turn start.
|
||||
* @returns the oldest message, or `undefined` when the queued FIFO is empty.
|
||||
*/
|
||||
dequeueQueued(): InboxMessage | undefined {
|
||||
return this.queuedMessages.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all steering messages (between steps).
|
||||
* @returns the drained messages in arrival order; the steering FIFO is left empty.
|
||||
*/
|
||||
drainSteering(): InboxMessage[] {
|
||||
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
|
||||
* a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away.
|
||||
*/
|
||||
clear(): void {
|
||||
this.queuedMessages.length = 0
|
||||
this.steeringMessages.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a queued message arrives or `cancel` resolves.
|
||||
* @param cancel - a promise whose resolution abandons the wait without a
|
||||
* message (the driver loop passes the agent's disposed promise so a parked
|
||||
* loop can exit).
|
||||
*/
|
||||
waitForQueued(cancel: Promise<void>): Promise<void> {
|
||||
if (this.hasWakingQueued) return Promise.resolve()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.wakeup = resolve
|
||||
void cancel.then(resolve)
|
||||
return promise.finally(() => {
|
||||
if (this.wakeup === resolve) this.wakeup = undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,7 @@
|
||||
import { Context, FiberState, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent,
|
||||
AgentFactory,
|
||||
@@ -26,12 +24,7 @@ import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import {
|
||||
bindReactLoopAgentContext,
|
||||
prepareReactLoopAgent,
|
||||
ReactLoopAgent,
|
||||
} from './agent.ts'
|
||||
import type { PreparedReactLoopAgent } from './agent.ts'
|
||||
import { DISPOSED_INTERRUPT_REASON, ReactLoopAgent } from './agent.ts'
|
||||
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
|
||||
|
||||
/** Fiber states that cannot own or serve a new lifecycle. */
|
||||
@@ -41,31 +34,43 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
||||
FiberState.FAILED,
|
||||
])
|
||||
|
||||
/** Factory-level ownership of every preparing or live transaction. */
|
||||
/** Factory-level ownership: live agent teardowns plus config startup work. */
|
||||
class FactoryOwnership {
|
||||
private accepting = true
|
||||
private readonly teardown = new AbortController()
|
||||
private readonly inactive = Promise.withResolvers<void>()
|
||||
private transactions = new Set<AgentCreationTransaction>()
|
||||
private readonly liveAgents = new Set<() => Promise<void>>()
|
||||
private startupTasks = new Set<Promise<void>>()
|
||||
|
||||
constructor(private readonly fiber: Context['fiber']) {}
|
||||
|
||||
/** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
|
||||
get signal(): AbortSignal {
|
||||
return this.teardown.signal
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
|
||||
}
|
||||
|
||||
track(transaction: AgentCreationTransaction): () => void {
|
||||
this.transactions.add(transaction)
|
||||
return () => { this.transactions.delete(transaction) }
|
||||
/** Track one live agent's shared teardown until it has run. */
|
||||
track(dispose: () => Promise<void>): () => void {
|
||||
this.liveAgents.add(dispose)
|
||||
return () => { this.liveAgents.delete(dispose) }
|
||||
}
|
||||
|
||||
/** Join config startup work that begins before an agent transaction exists. */
|
||||
/** Join config startup work that begins before an agent exists. */
|
||||
trackStartup(task: Promise<void>): void {
|
||||
this.startupTasks.add(task)
|
||||
const forget = () => { this.startupTasks.delete(task) }
|
||||
void task.then(forget, forget)
|
||||
}
|
||||
|
||||
/** Join one public create/resume continuation; factory dispose awaits its settlement. */
|
||||
trackWrapper(task: Promise<unknown>): void {
|
||||
this.trackStartup(task.then(() => undefined, () => undefined))
|
||||
}
|
||||
|
||||
/** Resolve `task`, or stop waiting when factory teardown begins. */
|
||||
async waitWhileActive(task: Promise<void>): Promise<void> {
|
||||
await Promise.race([task, this.inactive.promise])
|
||||
@@ -73,19 +78,29 @@ class FactoryOwnership {
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.accepting = false
|
||||
this.teardown.abort(new Error('agent loop is not active'))
|
||||
this.inactive.resolve()
|
||||
const reason = new Error('agent loop is not active')
|
||||
await Promise.all([
|
||||
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
||||
...[...this.liveAgents].map(dispose => dispose()),
|
||||
...this.startupTasks,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the public cancellation error while preserving a caller-supplied cause. */
|
||||
function signalAbortError(id: SessionId, signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason
|
||||
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||
/** Await `operation`, or throw the signal's reason as soon as it aborts. */
|
||||
async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, id: SessionId): Promise<T> {
|
||||
const toAbortError = (): Error => signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||
if (signal.aborted) throw toAbortError()
|
||||
const aborted = Promise.withResolvers<never>()
|
||||
const listener = (): void => { aborted.reject(toAbortError()) }
|
||||
signal.addEventListener('abort', listener, { once: true })
|
||||
try {
|
||||
return await Promise.race([Promise.resolve(operation), aborted.promise])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
|
||||
@@ -97,243 +112,15 @@ function resolveMaxParallelToolCalls(value: number | undefined): number {
|
||||
return maxParallelToolCalls
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-owned create/resume transaction through rollback-covered publication
|
||||
* and quiescent teardown. Resources remain private until the final registry
|
||||
* entry arbitrates identity.
|
||||
*/
|
||||
class AgentCreationTransaction {
|
||||
private active = true
|
||||
private failure: Error | undefined
|
||||
private readonly deactivation = Promise.withResolvers<void>()
|
||||
private readonly publication = Promise.withResolvers<void>()
|
||||
private readonly torndown = Promise.withResolvers<void>()
|
||||
private readonly wrapperCompletion = Promise.withResolvers<void>()
|
||||
private preparing: Promise<void> | undefined
|
||||
private driver: PreparedReactLoopAgent | undefined
|
||||
private scope: Scope | undefined
|
||||
private session: Session | undefined
|
||||
private lifecycleDispose: (() => Promise<void> | void) | undefined
|
||||
private detachSession: (() => void) | undefined
|
||||
private detachAgent: (() => void) | undefined
|
||||
private publishing = false
|
||||
private cleanupTask: Promise<void> | undefined
|
||||
private ownerFollowing = true
|
||||
private readonly ownerDispose: () => Promise<void> | void
|
||||
private readonly untrackFactory: () => void
|
||||
private readonly abortListener: (() => void) | undefined
|
||||
readonly ownerAgent: Context['agent']
|
||||
readonly ownerFiber: Context['fiber']
|
||||
|
||||
constructor(
|
||||
private readonly loopCtx: Context,
|
||||
private readonly ownerCtx: Context,
|
||||
private readonly ownership: FactoryOwnership,
|
||||
readonly id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
ownerCtx.fiber.assertActive()
|
||||
this.ownerAgent = ownerCtx.agent
|
||||
this.ownerFiber = ownerCtx.fiber
|
||||
if (!ownership.isActive()) throw new Error('agent loop is not active')
|
||||
this.ownerDispose = ownerCtx.effect(() => () => {
|
||||
if (!this.ownerFollowing) return
|
||||
return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
|
||||
}, `agentLoop.owner(${id})`)
|
||||
this.untrackFactory = ownership.track(this)
|
||||
if (signal === undefined) {
|
||||
this.abortListener = undefined
|
||||
} else {
|
||||
this.abortListener = () => {
|
||||
/* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */
|
||||
void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => {
|
||||
this.loopCtx.logger.error(error)
|
||||
})
|
||||
}
|
||||
signal.addEventListener('abort', this.abortListener, { once: true })
|
||||
if (signal.aborted) this.deactivate(signalAbortError(id, signal))
|
||||
}
|
||||
this.signal = signal
|
||||
}
|
||||
|
||||
private readonly signal: AbortSignal | undefined
|
||||
|
||||
/** Whether caller, provider, and optional parent-agent ownership remain live. */
|
||||
isActive(): boolean {
|
||||
return this.active
|
||||
&& this.ownership.isActive()
|
||||
&& this.ownerFiber.uid !== null
|
||||
&& !INACTIVE_STATES.has(this.ownerFiber.state)
|
||||
&& this.ownerAgent?.status !== 'disposed'
|
||||
}
|
||||
|
||||
/** Fail synchronously at every real lifecycle boundary after deactivation. */
|
||||
assertActive(): void {
|
||||
if (this.isActive()) return
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
|
||||
/** Race an external async operation against structural/signal deactivation. */
|
||||
async waitFor<T>(operation: PromiseLike<T> | T): Promise<T> {
|
||||
this.assertActive()
|
||||
return await Promise.race([
|
||||
Promise.resolve(operation),
|
||||
this.deactivation.promise.then(() => {
|
||||
/* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */
|
||||
throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
/** Construct the driver and scope, then install their complete ordered lifecycle. */
|
||||
prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
|
||||
this.assertActive()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
this.preparing = gate.promise
|
||||
try {
|
||||
this.session = session
|
||||
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls)
|
||||
this.driver = driver
|
||||
const agent = driver.agent
|
||||
const scope = createScope(this.loopCtx, agent)
|
||||
this.scope = scope
|
||||
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
|
||||
this.installLifecycle(scope, driver)
|
||||
this.assertActive()
|
||||
return agent
|
||||
} catch (error: unknown) {
|
||||
if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) {
|
||||
throw this.failure ?? this.disposalReason()
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
gate.resolve()
|
||||
this.preparing = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the exact scope disposer inside the ordered transaction effect. */
|
||||
private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void {
|
||||
this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) {
|
||||
// First yielded, disposed last.
|
||||
yield () => { this.finish() }
|
||||
yield scope.rawDispose
|
||||
yield () => {
|
||||
this.detachSession?.()
|
||||
this.detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
this.detachAgent?.()
|
||||
this.detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first.
|
||||
yield () => {
|
||||
this.deactivate(this.disposalReason())
|
||||
if (this.publishing) {
|
||||
return this.publication.promise.then(() => driver.dispose())
|
||||
}
|
||||
return driver.dispose()
|
||||
}
|
||||
}.bind(this), `agentLoop.lifecycle(${this.id})`)
|
||||
}
|
||||
|
||||
/** Publish the exact prepared objects and start the driver. */
|
||||
publish(source: SessionStartSource): AgentHandle {
|
||||
this.assertActive()
|
||||
const driver = this.driver
|
||||
/* v8 ignore next -- publish() is private and every caller invokes prepare() first. */
|
||||
if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`)
|
||||
const agent = driver.agent
|
||||
const session = this.session
|
||||
/* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */
|
||||
if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`)
|
||||
this.publishing = true
|
||||
try {
|
||||
this.detachSession = agent.ctx.sessions.enter(session)
|
||||
this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent)
|
||||
|
||||
agent.ctx.sessions.announce(session)
|
||||
this.assertActive()
|
||||
this.loopCtx.agents.announce(agent)
|
||||
this.assertActive()
|
||||
|
||||
driver.markPublished()
|
||||
agentEvents(this.loopCtx, agent).emit('agent/session-start', source)
|
||||
this.assertActive()
|
||||
driver.startDriver()
|
||||
return { agent, dispose: () => this.dispose() }
|
||||
} finally {
|
||||
this.publishing = false
|
||||
this.publication.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the transaction inactive exactly once and wake load/setup races. */
|
||||
private deactivate(reason: Error): void {
|
||||
if (!this.active) return
|
||||
this.active = false
|
||||
this.failure = reason
|
||||
this.deactivation.resolve()
|
||||
}
|
||||
|
||||
/** Choose the structural cause when an owner/factory effect starts teardown first. */
|
||||
private disposalReason(): Error {
|
||||
if (this.failure !== undefined) return this.failure
|
||||
if (!this.ownership.isActive()) return new Error('agent loop is not active')
|
||||
if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') {
|
||||
return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
return new Error(`agent "${this.id}" lifecycle disposed`)
|
||||
}
|
||||
|
||||
/** Complete ownership bookkeeping after every resource reached quiescence. */
|
||||
private finish(): void {
|
||||
this.untrackFactory()
|
||||
this.ownerFollowing = false
|
||||
void this.ownerDispose()
|
||||
this.torndown.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate and quiesce this transaction. The promise is memoized because
|
||||
* Cordis effect disposers are single-shot while handles promise shared
|
||||
* quiescence to every racing owner.
|
||||
*/
|
||||
dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise<void> {
|
||||
this.deactivate(reason)
|
||||
return (this.cleanupTask ??= (async () => {
|
||||
if (this.preparing !== undefined) await this.preparing
|
||||
if (this.lifecycleDispose !== undefined) {
|
||||
await this.lifecycleDispose()
|
||||
await this.torndown.promise
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.driver?.dispose()
|
||||
} finally {
|
||||
try {
|
||||
await this.scope?.dispose()
|
||||
} finally {
|
||||
this.finish()
|
||||
}
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
/** Mark the public create/resume continuation settled and detach its creation-only signal. */
|
||||
finishWrapper(): void {
|
||||
if (this.signal !== undefined && this.abortListener !== undefined) {
|
||||
this.signal.removeEventListener('abort', this.abortListener)
|
||||
}
|
||||
this.wrapperCompletion.resolve()
|
||||
}
|
||||
|
||||
/** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */
|
||||
async disposeForFactory(reason: Error): Promise<void> {
|
||||
await this.dispose(reason)
|
||||
await this.wrapperCompletion.promise
|
||||
}
|
||||
/** Prepared-but-unpublished agent resources sharing one memoized teardown. */
|
||||
interface PreparedAgent {
|
||||
agent: ReactLoopAgent
|
||||
/** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
|
||||
signal: AbortSignal
|
||||
/** Enter registries, announce, notify session-start, and start the machine. */
|
||||
publish(source: SessionStartSource): AgentHandle
|
||||
/** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -376,6 +163,9 @@ export interface Config {
|
||||
})[]
|
||||
}
|
||||
|
||||
/** Agent-loop configuration after defaults and load-time validation. */
|
||||
type ResolvedConfig = Config & { maxParallelToolCalls: number }
|
||||
|
||||
/** Reject self-contained identity conflicts before any configured agent starts. */
|
||||
function validateConfiguredAgents(agents: Config['agents']): void {
|
||||
const exactIdentities = new Map<SessionId, string>()
|
||||
@@ -409,18 +199,21 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
cwd: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
})).default([]),
|
||||
}) as unknown as z<Config>
|
||||
}) as z<Config>
|
||||
|
||||
/** Validated configuration owned by the agent-loop service. */
|
||||
readonly config: ResolvedConfig
|
||||
private readonly ownership: FactoryOwnership
|
||||
/** Resolved concurrency cap for every driver created by this factory. */
|
||||
private readonly maxParallelToolCalls: number
|
||||
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
|
||||
private readonly runtime: { ctx: Context }
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'agentLoop')
|
||||
validateConfiguredAgents(config.agents)
|
||||
this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
|
||||
this.config = {
|
||||
...config,
|
||||
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
|
||||
}
|
||||
validateConfiguredAgents(this.config.agents)
|
||||
this.ownership = new FactoryOwnership(ctx.fiber)
|
||||
this.runtime = { ctx }
|
||||
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
|
||||
@@ -429,7 +222,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
|
||||
for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
|
||||
const meta = cwd === undefined ? {} : { cwd }
|
||||
if (resumeSessionId === undefined || resumeSessionId === '') {
|
||||
const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
|
||||
@@ -499,10 +292,11 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
this.create(sessionId, agentOptions, meta)
|
||||
}
|
||||
|
||||
/** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
|
||||
/** Wait for a draining same-id lifecycle to finish registry teardown. */
|
||||
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
|
||||
const current = ownerCtx.agents.get(sessionId)
|
||||
if (current?.status !== 'disposed') return
|
||||
// Only an id still occupying a registry needs waiting for; a live healthy
|
||||
// occupant is a collision the create/resume below will surface itself.
|
||||
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return
|
||||
|
||||
const released = Promise.withResolvers<void>()
|
||||
const checkReleased = (): void => {
|
||||
@@ -521,6 +315,118 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the driver, scope, and one memoized reverse teardown for a new
|
||||
* agent. The teardown is registered with the factory and the owner fiber
|
||||
* BEFORE publication, so a mid-setup unload rolls everything back; `signal`
|
||||
* fuses caller cancellation with lifecycle teardown for setup awaits.
|
||||
*/
|
||||
private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
|
||||
ownerCtx.fiber.assertActive()
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
if (callerSignal?.aborted) {
|
||||
throw callerSignal.reason instanceof Error
|
||||
? callerSignal.reason
|
||||
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason })
|
||||
}
|
||||
const loopCtx = this.runtime.ctx
|
||||
|
||||
// Deactivation fuses three owners, each with its own reason: the caller's
|
||||
// cancellation signal, the owner fiber's unload, and factory teardown.
|
||||
// It is registered BEFORE any resource exists, over mutable slots, so an
|
||||
// unload arriving while the scope is still minting finds a working
|
||||
// disposer instead of a leak.
|
||||
const abort = new AbortController()
|
||||
const onCallerAbort = (): void => {
|
||||
abort.abort(callerSignal?.reason instanceof Error
|
||||
? callerSignal.reason
|
||||
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }))
|
||||
}
|
||||
const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }
|
||||
callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
|
||||
this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })
|
||||
|
||||
let machine: ReactLoopAgent | undefined
|
||||
let detachSession: (() => void) | undefined
|
||||
let detachAgent: (() => void) | undefined
|
||||
let disposing: Promise<void> | undefined
|
||||
// Reverse teardown, memoized so every racing owner awaits one quiescence:
|
||||
// stop the machine, leave the registries, unwind the scope, release
|
||||
// bookkeeping.
|
||||
const dispose = (): Promise<void> => (disposing ??= (async () => {
|
||||
abort.abort(new Error(`agent "${id}" lifecycle disposed`))
|
||||
callerSignal?.removeEventListener('abort', onCallerAbort)
|
||||
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
|
||||
try {
|
||||
// Disposal IS a disposed-cause cancel followed by quiescence. New work
|
||||
// sent after this point is the sender's bug — the registries are about
|
||||
// to drop the agent, so nothing should still hold it.
|
||||
if (machine !== undefined) {
|
||||
machine.cancel(DISPOSED_INTERRUPT_REASON)
|
||||
await Promise.allSettled([machine.done])
|
||||
await machine.scope.dispose()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
detachAgent?.()
|
||||
detachSession?.()
|
||||
} finally {
|
||||
untrack()
|
||||
void unfollowOwner()
|
||||
}
|
||||
}
|
||||
})())
|
||||
const untrack = this.ownership.track(dispose)
|
||||
let unfollowOwner: () => Promise<void> | void
|
||||
try {
|
||||
unfollowOwner = ownerCtx.effect(() => () => {
|
||||
// Owner disposal starts teardown but must not await its own disposer.
|
||||
if (disposing === undefined) {
|
||||
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
|
||||
void dispose()
|
||||
}
|
||||
}, `agentLoop.lifecycle(${id})`)
|
||||
} catch (error: unknown) {
|
||||
untrack()
|
||||
callerSignal?.removeEventListener('abort', onCallerAbort)
|
||||
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
|
||||
throw error
|
||||
}
|
||||
|
||||
const assertLive = (): void => {
|
||||
if (!abort.signal.aborted) return
|
||||
throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
|
||||
}
|
||||
try {
|
||||
const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
|
||||
assertLive()
|
||||
|
||||
return {
|
||||
agent,
|
||||
signal: abort.signal,
|
||||
publish: (source) => {
|
||||
assertLive()
|
||||
detachSession = agent.ctx.sessions.enter(session)
|
||||
detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent)
|
||||
agent.ctx.sessions.announce(session)
|
||||
assertLive()
|
||||
loopCtx.agents.announce(agent)
|
||||
assertLive()
|
||||
// A synchronous announce/session-start listener may have started
|
||||
// teardown; the machine is already live (send() works from the
|
||||
// session-start seam), so only the liveness recheck is owed.
|
||||
emitAgentEvent(loopCtx, agent, 'agent/session-start', source)
|
||||
assertLive()
|
||||
return { agent, dispose }
|
||||
},
|
||||
dispose,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
void dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent and session under one caller-supplied identity, owned by
|
||||
* the accessing fiber. Constructor-driven config calls mint a fresh combined
|
||||
@@ -531,51 +437,39 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @returns the published running agent.
|
||||
*/
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
|
||||
const loopCtx = this.runtime.ctx
|
||||
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
|
||||
const session = this.runtime.ctx.sessions.prepare(id, { meta })
|
||||
const prepared = this.prepare(this.ctx, id, options, session)
|
||||
try {
|
||||
const session = loopCtx.sessions.prepare(id, { meta })
|
||||
const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
|
||||
transaction.publish('startup')
|
||||
return agent
|
||||
return prepared.publish('startup').agent
|
||||
} catch (error: unknown) {
|
||||
void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
|
||||
void prepared.dispose()
|
||||
throw error
|
||||
} finally {
|
||||
transaction.finishWrapper()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an owned agent on a caller-supplied session id.
|
||||
* @param ownerCtx - caller context that structurally owns the transaction.
|
||||
* @param ownerCtx - caller context that structurally owns the lifecycle.
|
||||
* @param options - identities, session seed/metadata, loop options, setup, and cancellation.
|
||||
* @returns the published handle.
|
||||
*/
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
const agentOptions = options.agentOptions ?? {}
|
||||
const transaction = new AgentCreationTransaction(
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.sessionId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
transaction.assertActive()
|
||||
return transaction.publish('startup')
|
||||
} catch (error: unknown) {
|
||||
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
} finally {
|
||||
transaction.finishWrapper()
|
||||
}
|
||||
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
|
||||
const published = (async () => {
|
||||
try {
|
||||
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId)
|
||||
return prepared.publish('startup')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
this.ownership.trackWrapper(published)
|
||||
return published
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -593,36 +487,38 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
|
||||
/** Resume through an explicit persistence handle used by the deferred config path. */
|
||||
private async resumeWith(
|
||||
private resumeWith(
|
||||
ownerCtx: Context,
|
||||
persistence: SessionPersistence,
|
||||
options: ResumeAgentOptions,
|
||||
): Promise<AgentHandle> {
|
||||
const agentOptions = options.agentOptions ?? {}
|
||||
const transaction = new AgentCreationTransaction(
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.resumeSessionId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
|
||||
transaction.assertActive()
|
||||
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
const id = options.resumeSessionId
|
||||
const published = (async () => {
|
||||
// The load may outlive its owner: race it against caller cancellation,
|
||||
// owner-fiber unload, and factory teardown so a never-settling backend
|
||||
// cannot pin the identity.
|
||||
const fused = AbortSignal.any([
|
||||
...options.signal === undefined ? [] : [options.signal],
|
||||
this.ownership.signal,
|
||||
])
|
||||
const loaded = await raceAbort(persistence.load(id), fused, id)
|
||||
ownerCtx.fiber.assertActive()
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
const session = this.runtime.ctx.sessions.prepare(id, {
|
||||
seed: loaded.events,
|
||||
meta: loaded.meta,
|
||||
})
|
||||
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
transaction.assertActive()
|
||||
return transaction.publish('resume')
|
||||
} catch (error: unknown) {
|
||||
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
} finally {
|
||||
transaction.finishWrapper()
|
||||
}
|
||||
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
|
||||
try {
|
||||
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
return prepared.publish('resume')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
this.ownership.trackWrapper(published)
|
||||
return published
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
SessionId(`${String(session.id)}-invariant-rebuild`),
|
||||
structuredClone(events.slice(0, boundary)),
|
||||
)
|
||||
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
|
||||
const expected = rebuilt.deriveMessages()
|
||||
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
|
||||
fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
|
||||
}
|
||||
|
||||
@@ -1,825 +0,0 @@
|
||||
/**
|
||||
* Drives one agent across queued durable turns. Turn failures are contained so
|
||||
* later work can run; the session log, not this driver, owns conversation state.
|
||||
* See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
|
||||
* @module dsh-agent-loop/loop
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, 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'
|
||||
import type { TransmissionLog } from './request-log.ts'
|
||||
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 { agentMessage, type Inbox, type InboxMessage } from './inbox.ts'
|
||||
import type { TurnCancellation } from './cancellation.ts'
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
/** Distinguishes final model-request failures from failures in later step processing. */
|
||||
class TerminalModelRequestFailure extends Error {
|
||||
constructor(
|
||||
readonly requestError: RequestError,
|
||||
readonly failure: LlmFailure,
|
||||
) {
|
||||
super(failure.message, { cause: requestError })
|
||||
this.name = 'TerminalModelRequestFailure'
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
|
||||
function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const facts = finish.failure
|
||||
const error = new LlmError(facts.message, facts.code, {
|
||||
...facts.status === undefined ? {} : { status: facts.status },
|
||||
...facts.providerRetryAfterMs === undefined
|
||||
? {}
|
||||
: { providerRetryAfterMs: facts.providerRetryAfterMs },
|
||||
...facts.requestId === undefined ? {} : { requestId: facts.requestId },
|
||||
})
|
||||
return { error, failure: error.failure }
|
||||
}
|
||||
// stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `{ message, code? }` part of an error payload, omitting the
|
||||
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
|
||||
* The durable message renders the full cause chain: `turn/end` is the single
|
||||
* durable record of an in-turn failure, so a wrapper message alone (e.g.
|
||||
* `fetch failed`) would lose the diagnosis the session log exists to keep.
|
||||
*/
|
||||
function errorData(err: RequestError): { message: string; code?: string } {
|
||||
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
}
|
||||
|
||||
/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */
|
||||
function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure {
|
||||
const message = errorChain(err)
|
||||
return { ...failure, message: message === '<unrenderable value>' ? failure.message : message }
|
||||
}
|
||||
|
||||
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
|
||||
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'max-tokens':
|
||||
return { kind: 'max-tokens' }
|
||||
// stop / tool-calls / plugin-added kinds → no turn-end contribution
|
||||
// beyond the default `completed`. FinishReason is merge-extensible, so a
|
||||
// default (not assertNever) handles unknown kinds as ordinary success.
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal control-flow sentinel; durable classification comes only from the turn signal. */
|
||||
const TURN_INTERRUPTED = new Error('turn interrupted')
|
||||
|
||||
const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = {
|
||||
type: 'text',
|
||||
text: '\n\n## My request:\n',
|
||||
}
|
||||
|
||||
interface PreparedPromptMessage {
|
||||
data: PromptMessageData
|
||||
separateContexts: HookContext[]
|
||||
}
|
||||
|
||||
/** Bake declared prefix contexts into one reconstructable prompt message. */
|
||||
function preparePromptMessage(
|
||||
content: ContentBlock[],
|
||||
source: PromptMessageData['source'],
|
||||
contexts: readonly HookContext[],
|
||||
): PreparedPromptMessage {
|
||||
const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix')
|
||||
const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix')
|
||||
if (prefixContexts.length === 0) return { data: { content, source }, separateContexts }
|
||||
return {
|
||||
data: {
|
||||
content: [
|
||||
...prefixContexts.flatMap(context => context.content),
|
||||
PROMPT_PREFIX_REQUEST_DELIMITER,
|
||||
...content,
|
||||
],
|
||||
source,
|
||||
envelope: {
|
||||
displayContent: content,
|
||||
prefixContexts: prefixContexts.map(context => ({
|
||||
source: context.source,
|
||||
...context.meta === undefined ? {} : { meta: context.meta },
|
||||
})),
|
||||
},
|
||||
},
|
||||
separateContexts,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */
|
||||
function interruptionCheckpoint(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw TURN_INTERRUPTED
|
||||
}
|
||||
|
||||
/** Classify a supported turn interruption, with lifecycle disposal taking precedence. */
|
||||
function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined {
|
||||
if (handle.isDisposed()) return { kind: 'disposed' }
|
||||
const reason = agentInterruptReasonOf(signal)
|
||||
if (reason === undefined) return undefined
|
||||
switch (reason.kind) {
|
||||
case 'user':
|
||||
case 'parent':
|
||||
return { kind: 'aborted' }
|
||||
/* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */
|
||||
case 'disposed':
|
||||
return { kind: 'disposed' }
|
||||
/* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */
|
||||
default:
|
||||
return assertNever(reason, 'AgentInterruptReason')
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable agent controls supplied to the loop driver. */
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
readonly inbox: Inbox
|
||||
/** Maximum parallel-safe calls allowed in one step. */
|
||||
readonly maxParallelToolCalls: number
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
/** Install a fresh active-turn owner before the running notification. */
|
||||
installTurnCancellation(): TurnCancellation
|
||||
/** Clear only the exact owner whose turn reached its terminal event boundary. */
|
||||
clearTurnCancellation(cancellation: TurnCancellation): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
disposed: Promise<void>
|
||||
isDisposed(): boolean
|
||||
/** Whether queued work was cancelled before an active turn owner existed. */
|
||||
isPreRunCancelled(): boolean
|
||||
/** Clear the cause-less pre-run marker without affecting replacement work. */
|
||||
clearPreRunCancel(): void
|
||||
/** Settle idle waiters before pre-running cancellation publishes idle. */
|
||||
settleIdle(): void
|
||||
/** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
|
||||
readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive queued messages as independent durable turns until disposal. Plugin
|
||||
* failures end the current turn without terminating the driver. The caller
|
||||
* establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
|
||||
* orchestration recovers that exact Agent and captures its Session locally.
|
||||
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
||||
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
||||
* through.
|
||||
* @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state.
|
||||
* @throws when no initiating Agent is active.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
// Per-instance prefix and request-header state; conversation history remains in the session log.
|
||||
const transmission = createTransmissionLog()
|
||||
|
||||
const { session } = agent
|
||||
// Fused subject and scope carrier for every agent event below.
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
// An idle listener can enqueue and cancel replacement work before the next
|
||||
// wait is installed. Consume that empty marker before parking the driver.
|
||||
// A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on
|
||||
// hasWakingQueued, not hasQueued.
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasWakingQueued) {
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs before the eventual idle transition.
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasWakingQueued) {
|
||||
// Settle before publishing idle: the already-idle path has no status
|
||||
// transition, while an idle listener can register waiters for new work.
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let cancellation = handle.installTurnCancellation()
|
||||
handle.setStatus('running')
|
||||
if (handle.isDisposed()) {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
break
|
||||
}
|
||||
|
||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||
// status only when no waking replacement prompt was queued by that listener
|
||||
// (a lone quiet item parks at idle rather than driving a turn).
|
||||
if (cancellation.signal.aborted) {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
if (!handle.inbox.hasWakingQueued) {
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
cancellation = handle.installTurnCancellation()
|
||||
}
|
||||
|
||||
// Idle injection can add a turn, so derive the next number from the log.
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation)
|
||||
} catch (error: unknown) {
|
||||
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
|
||||
try {
|
||||
events.emit('agent/error', turn, 0, err)
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
} finally {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
}
|
||||
|
||||
// Late steering (arriving after runTurn returns, e.g. during the post-turn
|
||||
// flush) becomes queued input — unless terminal policy stopped the turn, in
|
||||
// which case it is dropped and must publish a discard so its enqueue is
|
||||
// still matched (the invariant only catches a NEGATIVE count, not a leak).
|
||||
const lateSteering = handle.inbox.drainSteering()
|
||||
if (terminalStopped) {
|
||||
if (lateSteering.length > 0) {
|
||||
events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true)))
|
||||
}
|
||||
} else {
|
||||
for (const message of lateSteering) handle.inbox.enqueue(message)
|
||||
}
|
||||
|
||||
// Park at idle unless a waking item still wants the model to run; a lone
|
||||
// quiet (`wakeup:false`) item stays queued but does not keep the loop busy.
|
||||
if (!handle.inbox.hasWakingQueued) handle.setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
cancellation: TurnCancellation,
|
||||
): Promise<boolean> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
const { signal } = cancellation
|
||||
const drainSteering = (): boolean => {
|
||||
const messages = handle.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
events.emit('agent/inbox/dequeue', agentMessage(message, true))
|
||||
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
|
||||
session.append('steering/message', {
|
||||
turn, ...prepared.data,
|
||||
...message.meta === undefined ? {} : { meta: message.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
for (const context of prepared.separateContexts) {
|
||||
session.append('user/message', {
|
||||
content: context.content,
|
||||
source: context.source,
|
||||
...context.meta === undefined ? {} : { meta: context.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
// Claim one queued message before opening its turn, but append it only after `turn/start`.
|
||||
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', agentMessage(message, false))
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
let step = 0
|
||||
let requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
|
||||
// Close the committed step once; pre-commit validation failure still escapes.
|
||||
const closeStep = (): void => {
|
||||
if (!stepOpen) return
|
||||
session.append('step/end', { turn, step })
|
||||
stepOpen = false
|
||||
}
|
||||
|
||||
// Record the durable turn failure once and contain the live error notification.
|
||||
const failTurn = (err: RequestError, failure?: LlmFailure): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
reason = failure === undefined
|
||||
? { kind: 'error', step, ...errorData(err) }
|
||||
: { kind: 'error', step, failure: durableFailure(err, failure) }
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
} catch {
|
||||
// contained: the error is already captured on `reason`; a throwing
|
||||
// agent/error listener must not prevent the turn from closing.
|
||||
}
|
||||
}
|
||||
|
||||
// Retire cancellation authority before publishing the terminal event. The
|
||||
// following durability flush is quiescent turn work, but no longer part of
|
||||
// the cancellable turn lifetime.
|
||||
const closeTurn = (): void => {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
|
||||
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
|
||||
// veto leaves no turn/start in the log and therefore owes no turn/end.
|
||||
session.append('turn/start', { turn, trigger })
|
||||
interruptionCheckpoint(signal)
|
||||
// The claimed message runs the `agent/prompt-submit` waterfall before it
|
||||
// becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
||||
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
||||
// throws) is caught below and the turn still closes.
|
||||
const promptDecision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source, signal,
|
||||
() => Promise.resolve<PromptDecision>({
|
||||
kind: 'allow',
|
||||
...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts },
|
||||
}),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (promptDecision.kind === 'block') {
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
|
||||
reason = { kind: 'rejected', reason: promptDecision.reason }
|
||||
} else {
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = promptDecision.content ?? message.content
|
||||
const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? [])
|
||||
session.append('user/message', {
|
||||
...prepared.data,
|
||||
...message.meta === undefined ? {} : { meta: message.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
// Separate contexts still enter THIS turn through inject(). Prefix
|
||||
// contexts are already baked into the user/message with their durable
|
||||
// display envelope, so appending them again would duplicate model input.
|
||||
for (const context of prepared.separateContexts) {
|
||||
agent.inject(context.content, {
|
||||
source: context.source,
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// A blocked prompt closes its zero-step turn as rejected.
|
||||
if (promptDecision.kind === 'block') break
|
||||
step += 1
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering()
|
||||
|
||||
// Assemble once before pre-step so listener work and the request share one prompt value.
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal))
|
||||
interruptionCheckpoint(signal)
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
// Compose the request-only prefix once per loop instance before the first
|
||||
// request boundary. It precedes all derived history and is recorded only
|
||||
// in the request header, not as session history.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await events.waterfall(
|
||||
'agent/session-prefix', emptyPrefix, signal,
|
||||
() => Promise.resolve(emptyPrefix),
|
||||
)
|
||||
// Never cache an interrupted composition; the next turn recomposes it.
|
||||
interruptionCheckpoint(signal)
|
||||
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
|
||||
}
|
||||
|
||||
// Await surface mutations outside the step before snapshotting history.
|
||||
await events.serial('agent/pre-step', turn, step, signal)
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
// Snapshot the exact log prefix before step/start: the reconstruction
|
||||
// boundary. Appends after this synchronous snapshot join the next request.
|
||||
const boundaryMessages = session.deriveMessages()
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
// Only a committed step/start creates a balancing obligation. A
|
||||
// pre-commit veto throws before this assignment; post-commit observers
|
||||
// are contained inside Session.append().
|
||||
stepOpen = true
|
||||
|
||||
// A synchronous step/start observer can cancel after the step opened.
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
let stepOutcome:
|
||||
| { hadToolCalls: boolean; finish: FinishReason }
|
||||
| { requestError: RequestError; failure: LlmFailure }
|
||||
| { error: RequestError }
|
||||
try {
|
||||
stepOutcome = await runStep(
|
||||
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TerminalModelRequestFailure) {
|
||||
stepOutcome = { requestError: error.requestError, failure: error.failure }
|
||||
} else {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
if ('requestError' in stepOutcome) {
|
||||
// Recovery observes a balanced failed step and the original provider
|
||||
// error while the failed step's signal remains the active owner.
|
||||
closeStep()
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted !== undefined) {
|
||||
reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
const defaultDecision: RequestErrorDecision = { action: 'fail' }
|
||||
let recoveryDecision: RequestErrorDecision = defaultDecision
|
||||
try {
|
||||
recoveryDecision = await events.waterfall(
|
||||
'agent/request-error', turn, step, stepOutcome.requestError,
|
||||
stepOutcome.failure, requestFailureHistory, signal,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
} catch (recoveryError: unknown) {
|
||||
ctx.logger.warn(
|
||||
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
|
||||
)
|
||||
}
|
||||
// Cancellation and disposal always win over either a recovery decision
|
||||
// or a recovery-listener failure.
|
||||
const recoveryInterrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (recoveryInterrupted !== undefined) {
|
||||
reason = recoveryInterrupted
|
||||
break
|
||||
}
|
||||
switch (recoveryDecision.action) {
|
||||
case 'retry':
|
||||
requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
|
||||
continue
|
||||
case 'fail':
|
||||
failTurn(stepOutcome.requestError, stepOutcome.failure)
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(recoveryDecision, 'agent request-error decision')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
// Steering that arrived during the failed step stays in the inbox —
|
||||
// runLoop re-enqueues it as a queued message, so an abort-then-steer
|
||||
// starts a fresh turn instead of being silently consumed.
|
||||
closeStep()
|
||||
const { error } = stepOutcome
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(error)
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
requestFailureHistory = Object.freeze([])
|
||||
|
||||
// Preserve max-token completion unless a later disposal, abort, or error wins.
|
||||
const stepReason = stepFinishReason(stepOutcome.finish)
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering()
|
||||
|
||||
try {
|
||||
await events.serial('agent/post-step', turn, step, signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
closeStep()
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(stepOutcome.error)
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
const postStepInterrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (postStepInterrupted !== undefined) {
|
||||
reason = postStepInterrupted
|
||||
closeStep()
|
||||
break
|
||||
}
|
||||
|
||||
closeStep()
|
||||
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
try {
|
||||
decision = await events.waterfall(
|
||||
'agent/turn-continuation', turn, defaultDecision, signal,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
} catch (error: unknown) {
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
// A continuation reason becomes next-step steering. Publish the same
|
||||
// enqueue event a public steer would, so the inbox ledger stays balanced
|
||||
// (every FIFO entry has a matching enqueue before its dequeue/discard).
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
// Detach and freeze the listener-owned reason like a public steer, so an
|
||||
// enqueue listener or the producer cannot mutate the durable/model-visible
|
||||
// steering message before it drains.
|
||||
const item: InboxMessage = deepFreeze({
|
||||
id: AgentMessageId(randomUUID()),
|
||||
content: structuredClone(decision.reason.content),
|
||||
source: structuredClone(decision.reason.source),
|
||||
contexts: [], wakeup: true,
|
||||
})
|
||||
handle.inbox.steer(item)
|
||||
events.emit('agent/inbox/enqueue', agentMessage(item, true))
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
// Pending steering overrides an ordinary stop.
|
||||
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// Terminal policy is monotonic and runs after ordinary continuation folding.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.serial('agent/turn-stop', turn, signal)
|
||||
interruptionCheckpoint(signal)
|
||||
terminalStop = stop !== undefined
|
||||
} catch (error: unknown) {
|
||||
// A broken terminal policy is an ordinary continuation failure: fail
|
||||
// this turn closed while leaving the driver alive for later turns.
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
if (terminalStop) {
|
||||
terminalStopped = true
|
||||
// Terminal stop discards steering but preserves ordinary queued prompts.
|
||||
// Publish a discard for every dropped steering item so the enqueue ⇒
|
||||
// dequeue-or-discard ledger stays balanced (the outstanding-count
|
||||
// invariant and correlation consumers must not be left with dangling ids).
|
||||
const dropped = handle.inbox.drainSteering()
|
||||
if (dropped.length > 0) {
|
||||
events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true)))
|
||||
}
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
if (!shouldContinue) break
|
||||
}
|
||||
|
||||
// Normal / inline-error loop exit: close the turn.
|
||||
closeTurn()
|
||||
} catch (error: unknown) {
|
||||
// Close only a turn whose start committed to the log.
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
closeTurn()
|
||||
}
|
||||
|
||||
// Flush through the store-owned durability checkpoint without killing the driver on failure.
|
||||
try {
|
||||
await ctx.sessions.flush(session)
|
||||
} catch (error: unknown) {
|
||||
// The turn is closed, so report the failed flush live rather than append outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
} catch {
|
||||
// contained: a throwing agent/error listener must not escape the loop.
|
||||
}
|
||||
}
|
||||
return terminalStopped
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one committed step: transform call config, log the request header, build
|
||||
* the request from the cached prefix plus the step-boundary snapshot, stream and
|
||||
* record the response, then execute tools. The caller has already assembled the
|
||||
* prompt, run `agent/pre-step`, snapshotted history, and opened the step.
|
||||
*/
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
events: AgentEventDispatch,
|
||||
handle: LoopHandle,
|
||||
turn: number,
|
||||
step: number,
|
||||
assembly: PromptAssembly,
|
||||
system: string,
|
||||
boundaryMessages: Message[],
|
||||
transmission: TransmissionLog,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session, options } = agent
|
||||
|
||||
// Seed the first request from agent options and later requests from the logged header;
|
||||
// detach and freeze so listeners must return an attributable replacement.
|
||||
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
|
||||
? session.requestHeader()!.config
|
||||
: { provider: options.provider ?? '', model: options.model ?? '' }))
|
||||
|
||||
// Listener replacements are recorded in the request header before dispatch.
|
||||
const config = await events.waterfall(
|
||||
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (!config.provider || !config.model) {
|
||||
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
|
||||
const sessionPrefix = transmission.sessionPrefix!
|
||||
|
||||
// Record the canonical header, including the otherwise-unlogged prefix, before dispatch.
|
||||
const header = canonicalHeader({
|
||||
config,
|
||||
...system ? { system } : {},
|
||||
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
||||
...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {},
|
||||
})
|
||||
recordRequestHeader(session, transmission, header)
|
||||
|
||||
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
|
||||
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
|
||||
provider: header.config.provider,
|
||||
model: header.config.model,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
|
||||
...header.system !== undefined ? { system: header.system } : {},
|
||||
...header.tools !== undefined ? { tools: header.tools } : {},
|
||||
...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},
|
||||
...header.config.maxTokens !== undefined ? { maxTokens: header.config.maxTokens } : {},
|
||||
...header.config.stop !== undefined ? { stop: header.config.stop } : {},
|
||||
sessionId: session.id,
|
||||
signal,
|
||||
}))
|
||||
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
const stream = ctx.llm.stream(request)
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
interruptionCheckpoint(signal)
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const failure = llmFailureOf(stream, error)
|
||||
if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure)
|
||||
throw error
|
||||
}
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
// Normalize failure finish chunks into the same path as thrown stream errors.
|
||||
const stepError = finishError(assembler.finish)
|
||||
if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure)
|
||||
|
||||
const recordAssistantMessage = (
|
||||
assembledContent: ContentBlock[],
|
||||
message: Message,
|
||||
preserveReplayState = true,
|
||||
): void => {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
content: message.content,
|
||||
provenance: assistantProvenance(
|
||||
header.config,
|
||||
assembler.replayState,
|
||||
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
|
||||
),
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
}
|
||||
|
||||
// A rejected result still records the successful provider call without retaining rejected output.
|
||||
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
|
||||
try {
|
||||
const processed = await events.waterfall(
|
||||
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
return processed
|
||||
} catch (error: unknown) {
|
||||
recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (assembler.finish.kind === 'max-tokens') {
|
||||
const assembled = assembler.message()
|
||||
const assembledContent = structuredClone(assembled.content)
|
||||
let message: Message = withoutToolCalls(assembled)
|
||||
message = withoutToolCalls(await processStepResult(assembledContent, message))
|
||||
// Preserve usage even when max-token truncation produced no content.
|
||||
recordAssistantMessage(assembledContent, message)
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
|
||||
// Record the post-waterfall message that tool dispatch uses.
|
||||
const assembled = assembler.message()
|
||||
const assembledContent = structuredClone(assembled.content)
|
||||
let message: Message = assembled
|
||||
message = await processStepResult(assembledContent, message)
|
||||
|
||||
// Every successful call records its completion anchor, including explicit
|
||||
// empty chunk provenance for a contentless, usage-less provider response.
|
||||
recordAssistantMessage(assembledContent, message)
|
||||
|
||||
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
|
||||
return handle.withToolBatch(async (acceptContext) => {
|
||||
await executeToolCalls(
|
||||
ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
|
||||
)
|
||||
return { hadToolCalls: true, finish: assembler.finish }
|
||||
})
|
||||
}
|
||||
|
||||
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
|
||||
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
|
||||
return {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
...contentUnchanged && replayState !== undefined ? { replayState } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function withoutToolCalls(message: Message): Message {
|
||||
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last turn number in a (possibly seeded) session log, or 0.
|
||||
* @param session - the session whose log is scanned for the latest `turn/start`.
|
||||
* @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
|
||||
*/
|
||||
export function lastTurnNumber(session: Session): number {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
return lastStart?.data.turn ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session log has an unmatched `turn/start`. Agent status is not
|
||||
* sufficient during pre-start and post-end windows.
|
||||
* @param session - the session whose log is inspected.
|
||||
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
|
||||
*/
|
||||
export function isTurnOpen(session: Session): boolean {
|
||||
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
|
||||
return last?.type === 'turn/start'
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Per-loop-instance request-header bookkeeping for reconstructability. The
|
||||
* comparison baseline is folded from the session log; a fresh instance anchors
|
||||
* it with an initial/resume snapshot and later logs full changed snapshots.
|
||||
*
|
||||
* @module dsh-agent-loop/request-log
|
||||
*/
|
||||
|
||||
import { headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Per-loop-instance bookkeeping: whether THIS instance has logged a header yet. */
|
||||
export interface TransmissionLog {
|
||||
/** True once this loop instance appended its anchoring `request/header` snapshot. */
|
||||
loggedHeader: boolean
|
||||
/**
|
||||
* The instance's composed session prefix (the `agent/session-prefix`
|
||||
* waterfall's deep-frozen product), cached on the instance's first
|
||||
* request-building step and reused verbatim for every request it sends —
|
||||
* the structural guarantee that the prefix never changes mid-session.
|
||||
* `undefined` until composed.
|
||||
*/
|
||||
sessionPrefix?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh bookkeeping for a newly-started loop instance.
|
||||
* @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
|
||||
*/
|
||||
export function createTransmissionLog(): TransmissionLog {
|
||||
return { loggedHeader: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the full header snapshot owed by this request: initial/resume for the
|
||||
* instance's first request, nothing when unchanged, or change otherwise.
|
||||
*
|
||||
* @param session - the session whose log explains the request.
|
||||
* @param state - this loop instance's bookkeeping (mutated on first log).
|
||||
* @param header - the canonical header the request will ACTUALLY use
|
||||
* (post-`agent/request`).
|
||||
*/
|
||||
export function recordRequestHeader(session: Session, state: TransmissionLog, header: EpochHeader): void {
|
||||
if (!state.loggedHeader) {
|
||||
session.append('request/header', { header, reason: session.requestHeader() === undefined ? 'initial' : 'resume' })
|
||||
state.loggedHeader = true
|
||||
return
|
||||
}
|
||||
// This instance logged a snapshot, so the fold is necessarily defined.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const baseline = session.requestHeader()!
|
||||
if (headerEquals(baseline, header)) return
|
||||
session.append('request/header', { header, reason: 'change' })
|
||||
}
|
||||
@@ -32,13 +32,16 @@ interface Slot {
|
||||
interface GroupOutcome {
|
||||
consumed: number
|
||||
aborted: boolean
|
||||
/** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */
|
||||
concluded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one assistant step's tool calls by their live concurrency mode.
|
||||
* Started calls receive ordered results. Abort drains them, records synthetic
|
||||
* results for unstarted calls, and returns with the signal still aborted after
|
||||
* accepting started-call context into the batch FIFO owned by the caller.
|
||||
* accepting started-call context through the caller-supplied acceptor (the
|
||||
* machine stages it on its outbox for the next step boundary).
|
||||
* The committed step's AgentLoop driver boundary supplies the initiating Agent
|
||||
* that becomes each explicit {@link ToolExecutionInput.agent}.
|
||||
*
|
||||
@@ -47,8 +50,7 @@ interface GroupOutcome {
|
||||
* @param step - current step number.
|
||||
* @param toolCalls - assistant calls in model order.
|
||||
* @param signal - abort signal shared by the step.
|
||||
* @param maxParallel - validated in-flight cap.
|
||||
* @param acceptContext - accepts committed result context into the active batch.
|
||||
* @param acceptContext - accepts committed result context for the next step boundary.
|
||||
*/
|
||||
export async function executeToolCalls(
|
||||
ctx: Context,
|
||||
@@ -56,9 +58,8 @@ export async function executeToolCalls(
|
||||
step: number,
|
||||
toolCalls: ToolCallBlock[],
|
||||
signal: AbortSignal,
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<void> {
|
||||
): Promise<{ concluded: boolean }> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
|
||||
@@ -75,6 +76,7 @@ export async function executeToolCalls(
|
||||
}))
|
||||
|
||||
let next = 0
|
||||
let concluded = false
|
||||
while (next < planned.length) {
|
||||
// Commit before classifying again so registry changes affect unstarted calls.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
@@ -82,14 +84,16 @@ export async function executeToolCalls(
|
||||
const mode = ctx.tools.executionMode(first.exec).kind
|
||||
const group = mode === 'parallel' ? planned.slice(next) : [first]
|
||||
const outcome = await runGroup(
|
||||
ctx, turn, step, group, mode, signal, maxParallel, acceptContext,
|
||||
ctx, turn, step, group, mode, signal, acceptContext,
|
||||
)
|
||||
next += outcome.consumed
|
||||
concluded ||= outcome.concluded
|
||||
if (outcome.aborted) {
|
||||
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return
|
||||
return { concluded }
|
||||
}
|
||||
}
|
||||
return { concluded }
|
||||
}
|
||||
|
||||
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
|
||||
@@ -116,10 +120,10 @@ async function runGroup(
|
||||
group: PlannedCall[],
|
||||
mode: ToolExecutionMode['kind'],
|
||||
signal: AbortSignal,
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<GroupOutcome> {
|
||||
const { session } = ctx.agents.requireInitiator()
|
||||
const { maxParallelToolCalls } = ctx.agentLoop.config
|
||||
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
||||
// Started slots retain their tool/call seq for result provenance.
|
||||
const callSeqs: number[] = group.map(() => -1)
|
||||
@@ -127,6 +131,7 @@ async function runGroup(
|
||||
let committed = 0
|
||||
let started = 0
|
||||
let aborted: boolean = signal.aborted
|
||||
let concluded = false
|
||||
|
||||
// `committed` advances only across contiguous model-order slots.
|
||||
const commitReady = async (): Promise<void> => {
|
||||
@@ -140,6 +145,7 @@ async function runGroup(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
||||
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
|
||||
for (const context of result.additionalContexts ?? []) acceptContext(context)
|
||||
concluded ||= result.concludesTurn === true
|
||||
committed++
|
||||
}
|
||||
}
|
||||
@@ -174,7 +180,7 @@ async function runGroup(
|
||||
}
|
||||
|
||||
const fillPool = async (): Promise<void> => {
|
||||
while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) {
|
||||
while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
|
||||
// Re-read later modes after ordered commits so registry changes can create a barrier.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
const nextCall = group[nextToStart]!
|
||||
@@ -206,11 +212,11 @@ async function runGroup(
|
||||
// Started calls and accepted context settle first; every remaining model
|
||||
// call then receives an ordered synthetic result before the turn aborts.
|
||||
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return { consumed: group.length, aborted: true }
|
||||
return { consumed: group.length, aborted: true, concluded }
|
||||
}
|
||||
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
|
||||
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
||||
return { consumed: started, aborted: false }
|
||||
return { consumed: started, aborted: false, concluded }
|
||||
}
|
||||
|
||||
/** Append the durable call/result pair for a model call skipped after cancellation. */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Agent-loop test migration guide (naive-machine contract)
|
||||
|
||||
The loop was rewritten in the naive-agent shape. `packages/core/agent-loop/src/agent.ts`
|
||||
is the single source of truth — read it before migrating a spec. Key changes:
|
||||
|
||||
## Event seams (old → new)
|
||||
|
||||
| Old seam | Replacement |
|
||||
|---|---|
|
||||
| `agent/pre-step` (serial, before step/start) | `agent/step` (serial, before EVERY request derives; same position) |
|
||||
| `agent/post-step` (serial, after tools, before step/end) | REMOVED — use `agent/step` of the next step, or `agent/idle` after the turn |
|
||||
| `agent/session-prefix` (waterfall, request-only prefix) | REMOVED — requests carry no unlogged prefix; durable context via `agent.inject()` at `agent/session-start` |
|
||||
| `agent/step-result` (waterfall, rewrite assistant msg) | REMOVED — the assembled message is recorded as-is |
|
||||
| `agent/request-error` (waterfall, retry/fail decision) | REMOVED — observe `agent/idle` with `reason.kind === 'error'`, repair, then `agent.retry()` |
|
||||
| `agent/turn-continuation` (waterfall, ContinuationDecision) | `agent/continue` (waterfall of `boolean`; handler `(agent, turn, signal, next)`) |
|
||||
| `agent/turn-stop` (serial, terminal stop) | REMOVED — `agent/continue` returning `false` stops the turn |
|
||||
| `agent/request` `(agent, turn, step, config, signal, next)` | `(agent, turn, step, signal, next)` — the config comes only from `await next()` |
|
||||
| `agent/prompt-submit` | unchanged |
|
||||
|
||||
New emit: `agent/idle (agent, turn, reason: IdleReason)` fires once per closed turn
|
||||
(after turn/end + flush, with `busy` already false, so listeners may synchronously
|
||||
`retry()`/`send()`). `IdleReason = completed | aborted | { kind: 'error', error, failure? }`.
|
||||
|
||||
## Verb semantics
|
||||
|
||||
- `send()` — unchanged (queued FIFO, one turn each).
|
||||
- `steer()` while running — enters the outbox; taken whole at the next step
|
||||
boundary. Steering left when the turn closes becomes a queued prompt.
|
||||
There is NO terminal-stop discard of steering anymore.
|
||||
- `inject()` while the machine is busy — enters the outbox (a `context/message`
|
||||
appears at the NEXT step boundary, not immediately). While idle — writes a
|
||||
one-shot turn (`turn/start(injection)` + `context/message` + `turn/end`) and
|
||||
requests a flush. Enclosure is decided by `busy`, NOT by scanning the log for
|
||||
an open turn.
|
||||
- `retry()` — NEW verb: re-opens a turn on the current log with trigger
|
||||
`{ kind: 'retry' }`. Throws while busy ("cannot retry while busy") and after
|
||||
disposal. Legal from a synchronous `agent/idle` listener.
|
||||
- `cancel()` — unchanged surface. No more "pre-run cancelled" bookkeeping:
|
||||
clearing the queue before a run starts simply means no run starts.
|
||||
|
||||
## Machine shape (timing-sensitive tests)
|
||||
|
||||
- `kick()` runs SYNCHRONOUSLY from `send()` when idle: status flips to
|
||||
`running` inside the `send()` call. There is no parked driver loop, no
|
||||
waitForQueued, no microtask collection window.
|
||||
- One `run()` = one turn. The idle tail (`idle()`) runs after turn/end +
|
||||
flush: it sets `busy=false`, emits `agent/idle`, requeues leftover steering,
|
||||
then either kicks the next turn or settles `whenIdle` waiters and flips
|
||||
status to `idle`. Status stays `running` continuously across queued turns.
|
||||
- `step/end` is appended INSIDE the step (after tools + the in-step outbox
|
||||
drain), before `agent/continue` runs. The old `post-step → step/end`
|
||||
window no longer exists.
|
||||
- Request messages = `session.deriveMessages()` snapshot taken right before
|
||||
`step/start` — no `messagePrefix`. `request/header` events no longer carry
|
||||
a `messagePrefix` field.
|
||||
- Provider/model config waterfall (`agent/request`) runs INSIDE the step
|
||||
(after step/start), seeded from agent options (first request) or the folded
|
||||
logged header (later requests).
|
||||
- The assembled assistant message is recorded verbatim (with replayState when
|
||||
present); there is no rewrite path and no "content-less anchor on rejection".
|
||||
- A model failure (thrown by the adapter or a failure finish chunk) closes the
|
||||
turn: balanced step/end + turn/end `{ kind:'error', step, failure }` +
|
||||
`agent/error` emit + `agent/idle` `{ kind:'error', error, failure }`.
|
||||
There are no in-turn recovery steps.
|
||||
- Cancellation classification: signal reason `user`/`parent` → turn/end
|
||||
`aborted`; disposal → `disposed`. IdleReason for both is `aborted`.
|
||||
- A blocked prompt (`prompt-submit` → block) records `prompt/blocked`, closes
|
||||
a zero-step turn `rejected` in turn/end, and emits `agent/idle`
|
||||
`{ kind: 'completed' }` (rejection is a policy outcome, not an error).
|
||||
- Accept-validation error message is now
|
||||
"agent message content and source must be losslessly JSON-serializable".
|
||||
- `dispose()` (the prepared disposer / factory teardown) returns `undefined`
|
||||
when the machine is not busy — do not `.resolves` it unconditionally; use
|
||||
`await Promise.resolve(dispose())`.
|
||||
|
||||
## What to do with tests of removed seams
|
||||
|
||||
- Rewrite the scenario against the nearest new seam when the protected
|
||||
behavior still exists (e.g. turn-stop tests → `agent/continue` returning
|
||||
false; request-error retry tests → `agent/idle` + `retry()` flows).
|
||||
- Delete tests whose subject no longer exists at all (session-prefix
|
||||
reconstruction, step-result rewrite provenance, post-step ordering windows,
|
||||
pre-run-cancel bookkeeping). Do not keep zombie tests alive by weakening
|
||||
their assertions.
|
||||
- Keep the durable-log invariants strong: balanced turn/step boundaries,
|
||||
ordered tool call/result pairs, header change tracking — those still hold.
|
||||
@@ -5,7 +5,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -57,12 +57,12 @@ describe('Agent', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('exclusive-driver'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session,
|
||||
)
|
||||
|
||||
expect(() => prepared.agent.ctx).toThrow('context is not bound')
|
||||
expect(() => prepareReactLoopAgent(
|
||||
ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session,
|
||||
))
|
||||
.toThrow('already has a concrete agent driver')
|
||||
|
||||
@@ -78,56 +78,11 @@ describe('Agent', () => {
|
||||
expect(agent.options).toBe(options)
|
||||
expect(agent.id).toBe('owned-bindings')
|
||||
expect(agent.session.id).toBe(agent.id)
|
||||
expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
|
||||
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('send() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('steer() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('inject() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -277,14 +232,15 @@ describe('Agent', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session,
|
||||
)
|
||||
const { agent } = prepared
|
||||
|
||||
// Start the loop to get the disposer; the agent waits for messages
|
||||
// (idle, never-resolving cancel), so it will stay idle.
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
prepared.start()
|
||||
const dispose = prepared.dispose
|
||||
|
||||
// First dispose
|
||||
const firstDisposal = dispose()
|
||||
@@ -301,12 +257,13 @@ describe('Agent', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session,
|
||||
)
|
||||
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
const dispose = prepared.startDriver()
|
||||
prepared.start()
|
||||
const dispose = prepared.dispose
|
||||
await dispose()
|
||||
await expect(prepared.agent.done).resolves.toBeUndefined()
|
||||
expect(prepared.agent.session.events).toEqual([])
|
||||
@@ -402,11 +359,12 @@ describe('Agent', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session,
|
||||
)
|
||||
const { agent } = prepared
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
prepared.start()
|
||||
const dispose = prepared.dispose
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Context } from 'cordis'
|
||||
import LlmService, { CallId, ContentBlock, 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, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
@@ -60,7 +60,7 @@ describe('session log records what agent/step-result actually produced', () => {
|
||||
const adapter = new MockAdapter([original, textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const executed: string[] = []
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'injected-tool',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -229,7 +229,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const executed: string[] = []
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -250,7 +250,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
source: { kind: 'plugin', plugin: 'abort-test' },
|
||||
}],
|
||||
}))
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'second',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -275,9 +275,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
order.push(`tool/result:${event.data.callId}:${outcome}`)
|
||||
break
|
||||
}
|
||||
// Injected context is a plugin-sourced user/message; the direct human
|
||||
// prompt (user source) is not tracked in this ordering.
|
||||
case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break
|
||||
case 'context/message': order.push('context/message'); break
|
||||
case 'steering/message': order.push('steering/message'); break
|
||||
case 'step/end': order.push('step/end'); break
|
||||
case 'turn/end': {
|
||||
@@ -334,7 +332,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -356,14 +354,13 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || isInjected(event)
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => isInjected(event) ? 'context/message' : event.type))
|
||||
.map(event => event.type))
|
||||
.toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events
|
||||
.filter(isInjected)
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before abort' }],
|
||||
@@ -381,7 +378,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
] satisfies StreamChunk[]])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'first',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -389,7 +386,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
return [{ type: 'text', text: 'first done' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -413,13 +410,12 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || isInjected(event)
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => isInjected(event) ? 'context/message' : event.type))
|
||||
.map(event => event.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events.find(isInjected)?.data.content)
|
||||
expect(events.find(event => event.type === 'context/message')?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'accepted after first result' }])
|
||||
})
|
||||
|
||||
@@ -431,7 +427,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'waiter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -460,7 +456,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
expect(agent.session.events
|
||||
.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before disposal' }],
|
||||
@@ -483,7 +479,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -492,7 +488,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'second',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -511,7 +507,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
send(agent, 'start a text-only turn')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content)
|
||||
expect(agent.session.events.find(event => event.type === 'context/message')?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'new turn context' }])
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
|
||||
})
|
||||
@@ -767,11 +763,11 @@ 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 resolved source; steering/message records its source', async () => {
|
||||
it('agent/queued carries the resolved source; steering/message records 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' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noop',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -781,14 +777,14 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = []
|
||||
ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering }))
|
||||
const queuedSources: { source: MessageSource; steering: boolean }[] = []
|
||||
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, contexts: [], steering: false })
|
||||
expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, contexts: [], steering: true })
|
||||
expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
|
||||
expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
|
||||
// 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.source] : [])
|
||||
@@ -803,39 +799,24 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedContexts: HookContext[] | undefined
|
||||
ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
if (subject !== agent || info.steering) return
|
||||
// Retain the exact notification references: cloning here would test the
|
||||
// listener's copy rather than the event/inbox ownership boundary.
|
||||
notifiedContent = info.content
|
||||
notifiedContent = acceptedContent
|
||||
notifiedSource = info.source
|
||||
notifiedContexts = info.contexts
|
||||
})
|
||||
|
||||
const contexts: HookContext[] = [{
|
||||
content: [{ type: 'text', text: 'accepted-context' }],
|
||||
source: { kind: 'plugin', plugin: 'context-source' },
|
||||
meta: { version: 1 },
|
||||
}]
|
||||
agent.send(content, { source, contexts })
|
||||
agent.send(content, { source })
|
||||
content[0]!.text = 'caller-mutated-send'
|
||||
source.plugin = 'caller-mutated-source'
|
||||
contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' }
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }])
|
||||
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
|
||||
expect(notifiedContexts).toEqual([{
|
||||
content: [{ type: 'text', text: 'accepted-context' }],
|
||||
source: { kind: 'plugin', plugin: 'context-source' },
|
||||
meta: { version: 1 },
|
||||
}])
|
||||
expect(Object.isFrozen(notifiedContent)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
|
||||
expect(Object.isFrozen(notifiedSource)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContexts)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContexts?.[0]?.content)).toBe(true)
|
||||
const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
expect(recorded).toContainEqual({
|
||||
content: [{ type: 'text', text: 'accepted-send' }],
|
||||
@@ -843,9 +824,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
})
|
||||
const request = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(request).toContain('accepted-send')
|
||||
expect(request).toContain('accepted-context')
|
||||
expect(request).not.toContain('caller-mutated-send')
|
||||
expect(request).not.toContain('caller-mutated-context')
|
||||
})
|
||||
|
||||
it('running steer() owns content and source before notification and delivery', async () => {
|
||||
@@ -854,7 +833,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'gate',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -866,12 +845,10 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
}))
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedContexts: HookContext[] | undefined
|
||||
ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
if (subject !== agent || !info.steering) return
|
||||
notifiedContent = info.content
|
||||
notifiedContent = acceptedContent
|
||||
notifiedSource = info.source
|
||||
notifiedContexts = info.contexts
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
@@ -879,86 +856,27 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(agent.status).toBe('running')
|
||||
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
|
||||
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
||||
const contexts: HookContext[] = [
|
||||
{
|
||||
content: [{ type: 'text', text: 'accepted-steering-prefix' }],
|
||||
source: { kind: 'plugin', plugin: 'steering-prefix' },
|
||||
placement: 'prompt-prefix',
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'accepted-steering-context' }],
|
||||
source: { kind: 'plugin', plugin: 'steering-context' },
|
||||
meta: { kind: 'separate-card' },
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }],
|
||||
source: { kind: 'plugin', plugin: 'steering-context-without-meta' },
|
||||
},
|
||||
]
|
||||
agent.steer(content, { source, contexts })
|
||||
agent.steer(content, { source })
|
||||
content[0]!.text = 'caller-mutated-steer'
|
||||
source.plugin = 'caller-mutated-source'
|
||||
contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-steering-prefix' }
|
||||
contexts[0]!.placement = 'separate'
|
||||
contexts[1]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context' }
|
||||
contexts[2]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context-without-meta' }
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
release.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }])
|
||||
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
|
||||
expect(notifiedContexts).toEqual([
|
||||
{
|
||||
content: [{ type: 'text', text: 'accepted-steering-prefix' }],
|
||||
source: { kind: 'plugin', plugin: 'steering-prefix' },
|
||||
placement: 'prompt-prefix',
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'accepted-steering-context' }],
|
||||
source: { kind: 'plugin', plugin: 'steering-context' },
|
||||
meta: { kind: 'separate-card' },
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }],
|
||||
source: { kind: 'plugin', plugin: 'steering-context-without-meta' },
|
||||
},
|
||||
])
|
||||
expect(Object.isFrozen(notifiedContent)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
|
||||
expect(Object.isFrozen(notifiedSource)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContexts)).toBe(true)
|
||||
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
|
||||
expect(recorded).toContainEqual({
|
||||
turn: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'accepted-steering-prefix' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'accepted-steer' },
|
||||
],
|
||||
content: [{ type: 'text', text: 'accepted-steer' }],
|
||||
source: { kind: 'plugin', plugin: 'accepted-source' },
|
||||
envelope: {
|
||||
displayContent: [{ type: 'text', text: 'accepted-steer' }],
|
||||
prefixContexts: [{
|
||||
source: { kind: 'plugin', plugin: 'steering-prefix' },
|
||||
}],
|
||||
},
|
||||
})
|
||||
const request = JSON.stringify(adapter.requests[1]!.messages)
|
||||
expect(request).toContain('accepted-steer')
|
||||
expect(request).toContain('accepted-steering-prefix')
|
||||
expect(request).toContain('accepted-steering-context')
|
||||
expect(request).toContain('accepted-steering-context-without-meta')
|
||||
expect(request).not.toContain('caller-mutated-steer')
|
||||
expect(request).not.toContain('caller-mutated-steering-prefix')
|
||||
expect(request).not.toContain('caller-mutated-steering-context')
|
||||
expect(request).not.toContain('caller-mutated-steering-context-without-meta')
|
||||
|
||||
const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message')
|
||||
const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context')
|
||||
expect(steeringIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(contextIndex).toBe(steeringIndex + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -983,11 +901,11 @@ describe('turn numbering continues across seeded sessions', () => {
|
||||
|
||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded,
|
||||
)
|
||||
const forked = prepared.agent
|
||||
prepared.markPublished()
|
||||
ctx2.effect(() => prepared.startDriver())
|
||||
ctx2.effect(() => { prepared.start(); return prepared.dispose })
|
||||
|
||||
const turns: number[] = []
|
||||
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
@@ -1501,7 +1419,7 @@ describe('tool result call identity', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { x: { type: 'number' } },
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import { Inbox } from '../src/inbox.ts'
|
||||
|
||||
function message(text: string) {
|
||||
return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
|
||||
}
|
||||
|
||||
function resolverPair() {
|
||||
let r!: () => void
|
||||
const p = new Promise<void>((resolve) => { r = resolve })
|
||||
return { promise: p, resolve: r }
|
||||
}
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('dequeues one queued message at a time in FIFO order', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue(message('first'))
|
||||
inbox.enqueue(message('second'))
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.hasQueued).toBe(false)
|
||||
expect(inbox.dequeueQueued()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('enqueue(msg, false) queues without waking a parked waiter', async () => {
|
||||
const inbox = new Inbox()
|
||||
let woke = false
|
||||
const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true })
|
||||
inbox.enqueue(message('quiet'), false)
|
||||
// The item is queued, but the parked waiter was not resolved by it.
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(woke).toBe(false)
|
||||
// A later waking enqueue resolves the same waiter.
|
||||
inbox.enqueue(message('loud'))
|
||||
await waiter
|
||||
expect(woke).toBe(true)
|
||||
})
|
||||
|
||||
it('pending() snapshots queued then steering without removing them', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue(message('q'))
|
||||
inbox.steer(message('s'))
|
||||
const pending = inbox.pending()
|
||||
expect(pending.map(p => p.steering)).toEqual([false, true])
|
||||
// Snapshot does not drain the FIFOs.
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
expect(inbox.hasSteering).toBe(true)
|
||||
})
|
||||
|
||||
it('pushes and drains steering messages separately from queued', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.steer(message('steer'))
|
||||
expect(inbox.hasQueued).toBe(false)
|
||||
expect(inbox.hasSteering).toBe(true)
|
||||
|
||||
const steering = inbox.drainSteering()
|
||||
expect(steering).toHaveLength(1)
|
||||
expect(inbox.hasSteering).toBe(false)
|
||||
})
|
||||
|
||||
it('waitForQueued returns immediately when a queued message is already present', async () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue(message('ready'))
|
||||
|
||||
const started = Date.now()
|
||||
await inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
|
||||
expect(Date.now() - started).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('waitForQueued resolves when a message is enqueued', async () => {
|
||||
const inbox = new Inbox()
|
||||
const waiter = inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
|
||||
// enqueue after starting the wait
|
||||
setTimeout(() => { inbox.enqueue(message('wake')) }, 5)
|
||||
await waiter
|
||||
})
|
||||
|
||||
it('waitForQueued resolves when the cancel promise resolves', async () => {
|
||||
const inbox = new Inbox()
|
||||
const { promise, resolve } = resolverPair()
|
||||
const waiter = inbox.waitForQueued(promise)
|
||||
resolve()
|
||||
await waiter
|
||||
})
|
||||
|
||||
it('waitForQueued overwrites the previous wakeup callback (only the latest waiter is notified)', async () => {
|
||||
const inbox = new Inbox()
|
||||
const { promise: p1, resolve: r1 } = resolverPair()
|
||||
|
||||
void inbox.waitForQueued(new Promise(() => {})) // first call, never resolved
|
||||
void inbox.waitForQueued(p1) // second call overwrites wakeup
|
||||
|
||||
// Cancelling the latest waiter clears the shared callback; enqueue must neither
|
||||
// wake the stale waiter nor fail on the cleared callback.
|
||||
r1()
|
||||
await p1
|
||||
|
||||
inbox.enqueue(message('hey'))
|
||||
})
|
||||
|
||||
it('clears wakeup in finally handler when enqueue resolves', async () => {
|
||||
const inbox = new Inbox()
|
||||
void inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
|
||||
// The wakeup is set. Now trigger it via enqueue → wakeup() calls resolve,
|
||||
// promise resolves, finally clears wakeup because wakeup === resolve.
|
||||
inbox.enqueue(message('wake'))
|
||||
// No explicit await needed — enqueue is synchronous, and the microtask
|
||||
// (finally) runs. The key coverage hit is finally with wakeup === resolve.
|
||||
})
|
||||
|
||||
it('finally handler does not clear wakeup when a different waiter overwrote it', async () => {
|
||||
// A stale waiter's finally must not clear the replacement waiter.
|
||||
const inbox = new Inbox()
|
||||
const { promise: c1, resolve: r1 } = resolverPair()
|
||||
|
||||
void inbox.waitForQueued(c1) // wakeup = resolve1, c1.then(resolve1)
|
||||
void inbox.waitForQueued(new Promise(() => {})) // wakeup = resolve2, cancel never resolves
|
||||
|
||||
r1()
|
||||
await c1
|
||||
|
||||
// The replacement remains registered and is resolved by enqueue.
|
||||
inbox.enqueue(message('hey'))
|
||||
})
|
||||
})
|
||||
@@ -47,15 +47,14 @@ describe('request-reconstruction invariant', () => {
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('requires the folded session prefix ahead of derived history', async () => {
|
||||
it('requires the messages to equal the boundary derivation exactly (no unlogged prefix)', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) })
|
||||
.not.toThrow()
|
||||
const extra = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
|
||||
.not.toThrow()
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) })
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -89,7 +89,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo back',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -118,27 +118,22 @@ describe('agent loop', () => {
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toContain('tool/call')
|
||||
expect(types).toContain('tool/result')
|
||||
const durableResult = agent.session.events.find(event => event.type === 'tool/result')
|
||||
expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false)
|
||||
})
|
||||
|
||||
it('persists presentation metadata projected from the canonical value', async () => {
|
||||
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
// A tool that returns the { content, meta } object form: the loop must
|
||||
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'writer',
|
||||
description: 'writes a file',
|
||||
parameters: { path: { type: 'string' } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => [{ type: 'text', text: 'ok' }],
|
||||
presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }),
|
||||
},
|
||||
async execute() {
|
||||
return 'a.txt'
|
||||
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -157,7 +152,7 @@ describe('agent loop', () => {
|
||||
// projecting this agent's configured model, so the model knows its own name.
|
||||
const ctx = await harness(adapter, 'You are a test agent on {{model}}.')
|
||||
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noop',
|
||||
description: 'does nothing',
|
||||
parameters: {},
|
||||
@@ -253,7 +248,7 @@ describe('agent loop', () => {
|
||||
['BigInt', { n: 1n }],
|
||||
['Map', new Map([['key', 'value']])],
|
||||
['class instance', new (class ResultMeta { x = 1 })()],
|
||||
])('rejects non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => {
|
||||
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
|
||||
textResponse('recovered'),
|
||||
@@ -263,12 +258,7 @@ describe('agent loop', () => {
|
||||
name: 'bad-meta',
|
||||
description: 'returns invalid durable metadata',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
presentationMeta: () => meta as unknown as JsonValue,
|
||||
},
|
||||
execute: () => Promise.resolve('apparent success'),
|
||||
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -281,16 +271,15 @@ describe('agent loop', () => {
|
||||
expect(result.data.callId).toBe('bad-meta-call')
|
||||
expect(result.data.isError).toBe(true)
|
||||
expect(result.data.meta).toBeUndefined()
|
||||
expect(result.data.error).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
|
||||
expect(result.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tool "bad-meta" returned invalid output: output.presentationMeta returned non-lossless JSON',
|
||||
text: 'Error: tool result must be losslessly JSON-serializable',
|
||||
}])
|
||||
}
|
||||
// The normalized failure was durably logged and fed back to the model; the
|
||||
// turn continued normally instead of failing after an apparent success.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('output.presentationMeta returned non-lossless JSON')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
|
||||
@@ -337,7 +326,7 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'slow',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -443,7 +432,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let visibleDuringTool = false
|
||||
const meta = { kind: 'deferred-test', version: 1 }
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noticer',
|
||||
description: 'injects a notice',
|
||||
parameters: {},
|
||||
@@ -505,7 +494,7 @@ describe('agent loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'invalid-injector',
|
||||
description: 'attempts an invalid context injection',
|
||||
parameters: {},
|
||||
@@ -513,7 +502,7 @@ describe('agent loop', () => {
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'invalid' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
meta: { bigint: 1n } as never,
|
||||
meta: { bigint: 1n },
|
||||
})
|
||||
}).toThrow('agent context must be losslessly JSON-serializable')
|
||||
return [{ type: 'text', text: 'rejected invalid context' }]
|
||||
@@ -574,7 +563,7 @@ describe('agent loop', () => {
|
||||
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -622,7 +611,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'echoed' }] },
|
||||
}))
|
||||
@@ -814,7 +803,7 @@ describe('agent loop', () => {
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
let executions = 0
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -854,7 +843,7 @@ describe('agent loop', () => {
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -941,7 +930,7 @@ describe('agent loop', () => {
|
||||
textResponse('continued after tool call'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -1224,7 +1213,6 @@ describe('agent loop', () => {
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
|
||||
expect(() => { send(agent, 'too late') }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('creates agents from config on startup', async () => {
|
||||
@@ -1273,7 +1261,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
|
||||
@@ -9,9 +9,9 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
|
||||
@@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC
|
||||
function gatedTool(name: string, parallel: boolean) {
|
||||
const gates = new Map<string, () => void>()
|
||||
const started: string[] = []
|
||||
const tool = defineContentToolFixture({
|
||||
const tool = defineTool({
|
||||
name,
|
||||
description: `gated ${name}`,
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } },
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] },
|
||||
}))
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
|
||||
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
|
||||
}))
|
||||
@@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const replacement = gatedExclusiveTool('x')
|
||||
const disposeSafe = ctx.tools.register(defineContentToolFixture({
|
||||
const disposeSafe = ctx.tools.register(defineTool({
|
||||
name: 'x',
|
||||
description: 'initially safe',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] },
|
||||
}))
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'replace',
|
||||
description: 'replace x',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -280,7 +280,8 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
expect(() => new AgentLoop(ctx, { agents: [] })).not.toThrow()
|
||||
const loop = new AgentLoop(ctx, { agents: [] })
|
||||
expect(loop.config.maxParallelToolCalls).toBe(DEFAULT_MAX_PARALLEL_TOOL_CALLS)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -539,14 +540,10 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({
|
||||
callId: e.data.callId,
|
||||
isError: e.data.isError,
|
||||
errorInfo: e.data.error,
|
||||
})))
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
|
||||
.toEqual([
|
||||
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
])
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result'
|
||||
|| (e.type === 'user/message' && e.data.source.kind === 'plugin'))
|
||||
@@ -570,7 +567,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
const gated = gatedParallelTool('p')
|
||||
const exclusive: string[] = []
|
||||
ctx.tools.register(gated.tool)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'x',
|
||||
description: 'exclusive',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the
|
||||
* fused dispatcher so subject and scope key cannot diverge; registry lifecycle
|
||||
* code instead captures one stable carrier for both edges.
|
||||
* Agent-scoped dispatch helpers. An agent-subject event travels with the
|
||||
* agent's scope carrier as `thisArg` (so scoped listeners filter to their own
|
||||
* agent) and the agent itself as the first argument. Composable seams are
|
||||
* plain `ctx.waterfall(carrier, name, agent, …, next)` calls at the machine's
|
||||
* call sites — concrete event names type-check against the real Cordis
|
||||
* overloads, so no generic wrapper (and none of its casts) is needed. The one
|
||||
* helper here is {@link emitAgentEvent}: a contained fire-and-forget emit.
|
||||
* @module @deepseek-ai/dsh-agent/dispatch
|
||||
*/
|
||||
|
||||
@@ -11,11 +15,6 @@ import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from './types.ts'
|
||||
|
||||
/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
|
||||
type Params<F> = F extends (...args: infer P) => unknown ? P : never
|
||||
/** Extract the return type from an event handler type. */
|
||||
type Return<F> = F extends (...args: never[]) => infer R ? R : never
|
||||
|
||||
/**
|
||||
* The event names whose subject is an agent: handler parameters start with an
|
||||
* `Agent` AND the handler declares a `Scoped<Agent>` `this` (the scope-carrier
|
||||
@@ -30,84 +29,43 @@ export type AgentSubjectEvent = {
|
||||
}[keyof Events]
|
||||
|
||||
/** The event arguments AFTER the injected agent subject. */
|
||||
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never
|
||||
type Tail<K extends AgentSubjectEvent> = Events[K] extends (...args: infer P) => unknown
|
||||
? P extends [Agent, ...infer R] ? R : never
|
||||
: never
|
||||
|
||||
/**
|
||||
* The fused dispatcher {@link agentEvents} returns: each method dispatches the
|
||||
* named agent-subject event with the agent's scope carrier as `thisArg` and
|
||||
* the agent itself injected as the first event argument.
|
||||
* The scope carrier for an agent-subject dispatch: the agent fused as both
|
||||
* the carrier key and the event subject, so the two cannot diverge. Pass it
|
||||
* as the `thisArg` of `ctx.serial` / `ctx.waterfall` for agent events.
|
||||
* @param agent - the subject agent.
|
||||
* @returns the fused carrier.
|
||||
*/
|
||||
export interface AgentEventDispatch {
|
||||
/**
|
||||
* Fire-and-forget notification in the agent's scope. Every listener is
|
||||
* invoked; synchronous throws and returned-promise rejections are logged and
|
||||
* contained per listener, so a notification cannot veto lifecycle progress
|
||||
* or starve a later observer.
|
||||
* @param name - the agent-subject event to emit.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
*/
|
||||
emit<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): void
|
||||
/**
|
||||
* Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
|
||||
* @param name - the agent-subject event to dispatch.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
* @returns the serial chain's result (the first bail value, if any).
|
||||
*/
|
||||
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
|
||||
/**
|
||||
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
|
||||
* declared event parameters already end with the `next` callback, so `rest`
|
||||
* is exactly the event's arguments after the injected agent — the final
|
||||
* element being the innermost `next` (the default the listener chain wraps).
|
||||
* @param name - the agent-subject event to dispatch.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
* @returns the waterfall's composed result.
|
||||
*/
|
||||
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]>
|
||||
export function agentCarrier(agent: Agent): Scoped<Agent> {
|
||||
return scopeTarget(agent, agent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dispatcher that couples the agent subject to its scope carrier.
|
||||
* Fire-and-forget notification in the agent's scope. Every listener is
|
||||
* invoked; synchronous throws and returned-promise rejections are logged and
|
||||
* contained per listener, so a notification cannot veto lifecycle progress or
|
||||
* starve a later observer. (Raw Cordis `emit` maps callbacks unguarded — one
|
||||
* synchronous throw would starve the rest and escape into the caller.)
|
||||
* @param ctx - the context to dispatch through (any context of the app).
|
||||
* @param agent - the subject agent; also the scope-carrier key.
|
||||
* @returns the fused dispatcher.
|
||||
* @param name - the agent-subject event to emit.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
*/
|
||||
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
|
||||
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
|
||||
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
|
||||
// list for the matching thisArg overload, but TypeScript cannot relate the
|
||||
// generic Tail<K> spread back to that overload's conditional parameter
|
||||
// tuple — hence one contained, shape-preserving cast per method.
|
||||
return {
|
||||
emit(name, ...rest) {
|
||||
// Cordis emit invokes callbacks through Array.map: one synchronous throw
|
||||
// starves later listeners, and returned promises are discarded. Agent
|
||||
// notifications are non-vetoing, so resolve the same filtered callback
|
||||
// set ourselves and contain both failure modes independently.
|
||||
const args: unknown[] = [carrier, name, agent, ...rest]
|
||||
const callbacks = ctx.events.dispatch('emit', args)
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
async serial(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
|
||||
return await serial(carrier, name, agent, ...rest)
|
||||
},
|
||||
waterfall(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
|
||||
return waterfall(carrier, name, agent, ...rest)
|
||||
},
|
||||
export function emitAgentEvent<K extends AgentSubjectEvent>(ctx: Context, agent: Agent, name: K, ...rest: Tail<K>): void {
|
||||
const args: unknown[] = [agentCarrier(agent), name, agent, ...rest]
|
||||
for (const callback of ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { Agent, AgentOptions } from './types.ts'
|
||||
export * from './types.ts'
|
||||
export { agentInterruptReasonOf } from './cancellation.ts'
|
||||
export * from './llm-target.ts'
|
||||
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
export { agentCarrier, assembleContextFor, emitAgentEvent } from './dispatch.ts'
|
||||
export type { AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
|
||||
@@ -19,9 +19,6 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
if (previous === status) {
|
||||
fail(`agent/status repeated ${status} (no-op transition)`)
|
||||
}
|
||||
if (previous === 'disposed') {
|
||||
fail(`agent/status left terminal state disposed → ${status}`)
|
||||
}
|
||||
lastStatus.set(agent, status)
|
||||
}, { global: true })
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
|
||||
})
|
||||
const disposeRequest = agentCtx.on(
|
||||
'agent/request',
|
||||
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
|
||||
async (_agent, _turn, _step, _signal, next): Promise<LlmCallConfig> => {
|
||||
const resolved = await next()
|
||||
const selected = target.assembled
|
||||
return selected === undefined ? resolved : {
|
||||
|
||||
+146
-336
@@ -2,13 +2,29 @@
|
||||
* Public agent types and live-runtime events. Durable transcript facts and
|
||||
* turn/step boundaries remain `@deepseek-ai/dsh-session` events.
|
||||
*
|
||||
* The agent is a naive message machine over the session log: prompts queue
|
||||
* (one turn each), steering/context ride the outbox (taken whole at every
|
||||
* step boundary), and the log re-derives the request history each step — so
|
||||
* "edit history between steps" needs no dedicated seam. The extension surface
|
||||
* is deliberately small:
|
||||
*
|
||||
* - `agent/prompt-submit` (waterfall): veto/rewrite a claimed prompt.
|
||||
* - `agent/request` (waterfall): replace the call config per request.
|
||||
* - `agent/step` (serial): awaited before every request is built — inject
|
||||
* context, steer, or edit the log here; the request derives after it.
|
||||
* - `agent/stopping` (serial): the turn is about to close — steer to object.
|
||||
* - a tool result carrying `concludesTurn` ends the turn at its step (data,
|
||||
* not a hook): the terminal-tool pattern.
|
||||
* - `agent/idle` (emit): one per turn close, carrying why it ended. Error
|
||||
* recovery is a consumer loop: observe an error idle, fix (edit the log,
|
||||
* wait out a rate limit), then `agent.retry()`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/types
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
@@ -26,147 +42,62 @@ export interface AgentOptions {
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which inbox queue a {@link Agent.send} item joins:
|
||||
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
|
||||
* - `next-step` — the item joins the active turn between steps as steering,
|
||||
* or, when no turn is active, is promoted per its `wakeup` flag.
|
||||
*/
|
||||
export type SendTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* An omitted source attests direct human input as `{ kind: 'user' }` and may
|
||||
* authorize policy consumers, so non-human producers must label their content.
|
||||
*/
|
||||
/** One queued prompt or steering item and its atomic model-facing context. */
|
||||
export interface SendOptions {
|
||||
/** Queue the item joins; defaults to `next-turn`. */
|
||||
target?: SendTarget
|
||||
/**
|
||||
* Whether this item makes the model run: wake a parked driver (`next-turn`)
|
||||
* or force a continuation step (`next-step` while running). Defaults to
|
||||
* `true`. 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
|
||||
source?: MessageSource
|
||||
/**
|
||||
* Model-facing contexts captured with this inbox item. A queued prompt exposes
|
||||
* them through the default `agent/prompt-submit` allow decision, while steering
|
||||
* records them directly at its next checkpoint.
|
||||
*/
|
||||
/** Explicit producer attribution; callers may not inherit human authority by omission. */
|
||||
source: MessageSource
|
||||
/** Context snapshotted with this item and admitted at the same boundary. */
|
||||
contexts?: HookContext[]
|
||||
/** Opaque JSON state retained on the durable message but hidden from the model. */
|
||||
}
|
||||
|
||||
/** Options for synthetic context injection. */
|
||||
export interface InjectOptions {
|
||||
/** Explicit producer attribution. */
|
||||
source: MessageSource
|
||||
/** Opaque durable state omitted from the model projection. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
|
||||
export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>
|
||||
|
||||
/**
|
||||
* Opaque id assigned to one accepted {@link Agent.send} message; returned by
|
||||
* `send` and carried on its `agent/inbox/*` events for correlation.
|
||||
* An agent's ACTIVITY state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work) or `running` (the machine is
|
||||
* draining work). Lifecycle is a separate axis: an agent leaving its host is
|
||||
* announced by `agent/disposed` and observable as `ctx.agents.get(id)` no
|
||||
* longer returning it — not as a status value.
|
||||
*/
|
||||
export type AgentMessageId = Branded<'AgentMessageId'>
|
||||
export type AgentStatus = 'idle' | 'running'
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link AgentMessageId}.
|
||||
* @param id - the generated message id.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function AgentMessageId(id: string): AgentMessageId {
|
||||
return id as AgentMessageId
|
||||
}
|
||||
|
||||
/**
|
||||
* One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live
|
||||
* events. `id` is the value `send` returned to the caller, stable across this
|
||||
* message's enqueue, dequeue, and discard events. Source defaults are already
|
||||
* applied, so these are the exact values the item was accepted with. `steering`
|
||||
* is true for a `next-step` item drained between steps; a `next-turn` item is
|
||||
* claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is
|
||||
* durable model-hidden state that lands on the eventual `user/message`/
|
||||
* `steering/message`, not live-event routing data.
|
||||
*/
|
||||
export interface AgentMessage {
|
||||
/** The id `send` returned for this message. */
|
||||
id: AgentMessageId
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
contexts: HookContext[]
|
||||
/** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */
|
||||
steering: boolean
|
||||
/** Whether the item is marked to wake the driver or force a continuation. */
|
||||
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/discard` fires.
|
||||
*/
|
||||
keepInbox?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (the driver is draining
|
||||
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
|
||||
* transition leaves it, and `send`/`followup`/`steer`/`inject` throw).
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
/** Model-facing context injected by a listener or atomically attached to one inbox message. */
|
||||
/** Model-facing context injected by a listener or atomically attached to one inbox item. */
|
||||
export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
/**
|
||||
* Model placement. Absent or `separate` records an independent injected
|
||||
* `user/message`; `prompt-prefix` prepends this context and a stable
|
||||
* request delimiter to the same user-role message as its attached prompt.
|
||||
*/
|
||||
/** `prompt-prefix` bakes this context into its prompt; absent/`separate` records an independent message. */
|
||||
placement?: 'separate' | 'prompt-prefix'
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
/** Opaque durable state omitted from the model projection. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt. Each
|
||||
* `additionalContexts` entry follows its declared placement: separate context
|
||||
* message by default, or a prefix inside the prompt's user-role message.
|
||||
* `block` records a durable `prompt/blocked` and ends the claimed prompt's
|
||||
* zero-step turn as rejected. An `allow` returned by a listener is
|
||||
* authoritative: a listener wrapping `next()` preserves downstream `content`
|
||||
* and `additionalContexts` unless it intentionally replaces them.
|
||||
* `additionalContexts` entry follows its declared placement. `block` records
|
||||
* a durable `prompt/blocked` and ends the claimed prompt's zero-step turn as
|
||||
* rejected. A listener wrapping `next()` preserves downstream fields unless
|
||||
* it intentionally replaces them.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
| { kind: 'block'; reason: string }
|
||||
|
||||
/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
|
||||
export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
|
||||
|
||||
/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */
|
||||
export type RequestErrorDecision = { action: 'fail' } | { action: 'retry' }
|
||||
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
export type RequestError = Error & { code?: string }
|
||||
|
||||
/**
|
||||
* The terminal subset of {@link ContinuationDecision}. A listener on
|
||||
* `agent/turn-stop` returns this to make the already-composed continuation
|
||||
* outcome terminal; `undefined` abstains.
|
||||
* Why a turn ended, reported live on `agent/idle` right after the turn's
|
||||
* durable `turn/end` and flush. `error` carries the live Error (and, for
|
||||
* model-request failures, the adapter-normalized facts) so a recovery
|
||||
* consumer can decide to repair and {@link Agent.retry}.
|
||||
*/
|
||||
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
export type IdleReason =
|
||||
| { kind: 'completed' }
|
||||
| { kind: 'aborted' }
|
||||
| { kind: 'error'; error: Error; failure?: LlmFailure }
|
||||
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
@@ -179,104 +110,60 @@ export type AgentCancelCause =
|
||||
/** Runtime reason carried by the signal that controls one live turn. */
|
||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||
|
||||
/**
|
||||
* Public agent handle; its concrete implementation is internal to
|
||||
* `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so
|
||||
* the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer},
|
||||
* {@link Agent.inject}) are shared concrete delegates over the single abstract
|
||||
* {@link Agent.send} primitive; concrete drivers implement `send` once.
|
||||
*/
|
||||
export abstract class Agent {
|
||||
/** Public live-agent handle; driving methods have no contract after disposal. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
abstract readonly id: SessionId
|
||||
/** The provider route and model this agent's requests use. */
|
||||
abstract readonly options: AgentOptions
|
||||
/** The live session this agent drives; its log is the durable source of truth. */
|
||||
abstract readonly session: Session
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
abstract readonly status: AgentStatus
|
||||
readonly id: SessionId
|
||||
readonly options: AgentOptions
|
||||
readonly session: Session
|
||||
readonly status: AgentStatus
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
abstract readonly ctx: Context
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
|
||||
* Detaches, validates, and freezes one lossless-JSON item, then routes it:
|
||||
*
|
||||
* - `next-turn` (default) queues an item that becomes the sole ordinary
|
||||
* message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a
|
||||
* parked driver, while `wakeup:false` queues without waking.
|
||||
* - `next-step` with `wakeup:true` submits steering into the active turn
|
||||
* (idle falls back to a woken `next-turn`).
|
||||
* - `next-step` with `wakeup:false` injects durable model-facing context
|
||||
* without running the model: an open turn joins at the current log position
|
||||
* (deferred behind an executing tool batch until it settles), and an idle
|
||||
* inject records a one-shot turn with its own durability checkpoint.
|
||||
*
|
||||
* Attached contexts share the same snapshot and ownership boundary. Invalid
|
||||
* input throws synchronously before any notification, enqueue, or append.
|
||||
* @param content - the model-facing content blocks to deliver.
|
||||
* @param options - target queue, wakeup decision, source, contexts, and meta.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
* Queue one detached, frozen lossless-JSON prompt. Each claimed prompt is
|
||||
* the sole ordinary message in its FIFO-ordered turn; the next claimed
|
||||
* prompt waits for that turn's checkpoint.
|
||||
* Invalid input throws synchronously before notification or enqueue.
|
||||
*/
|
||||
abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId
|
||||
send(content: ContentBlock[], options: SendOptions): void
|
||||
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn. An effective call first emits `agent/cancel-requested` with the
|
||||
* resolved typed cause. The first cause wins for the active turn, and
|
||||
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
|
||||
* means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
|
||||
* later work. The active turn snapshots and freezes the cause.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
* Submit steering while the agent is `running`: it enters the outbox and is
|
||||
* taken whole at the next step boundary, before the next request. Steering
|
||||
* left over when the turn closes queues for a turn of its own. When idle,
|
||||
* delegates to {@link send}.
|
||||
*/
|
||||
abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
abstract whenIdle(): Promise<void>
|
||||
steer(content: ContentBlock[], options: SendOptions): void
|
||||
|
||||
/**
|
||||
* Queue an ordinary follow-up turn and wake the driver — the
|
||||
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
|
||||
* ordinary message of its own turn.
|
||||
* @param content - the prompt content blocks.
|
||||
* @param options - source and attached contexts.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
* Stage detached model-facing context without running the model: it enters
|
||||
* the outbox and rides along with whatever runs next — the next step of the
|
||||
* running turn (never between a tool-call batch and its results), or the
|
||||
* next turn when idle.
|
||||
*/
|
||||
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
||||
return this.send(content, { ...options, target: 'next-turn', wakeup: true })
|
||||
}
|
||||
inject(content: ContentBlock[], options: InjectOptions): void
|
||||
|
||||
/**
|
||||
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
||||
* {@link send}. An open turn records it at the next steering checkpoint before
|
||||
* a request or continuation decision; policy may stop before another step.
|
||||
* After turn close and its checkpoint, any remainder is queued for a later
|
||||
* turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it.
|
||||
* Idle steering falls back to a woken follow-up turn.
|
||||
* @param content - the steering content blocks.
|
||||
* @param options - source and attached contexts.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
* Clear all queued and outbox work and abort the active turn. An effective
|
||||
* call first emits `agent/cancel-requested` with the resolved typed cause;
|
||||
* the first cause wins for the active turn. Omission means `{ kind: 'user' }`.
|
||||
* Idle cancellation is a no-op and does not arm later work.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
||||
return this.send(content, { ...options, target: 'next-step', wakeup: true })
|
||||
}
|
||||
cancel(cause?: AgentInterruptReason): void
|
||||
|
||||
/**
|
||||
* Append detached model-facing context without running the model — the
|
||||
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins
|
||||
* at the current log position unless the current tool batch is executing;
|
||||
* then it waits FIFO until that batch settles and drains before turn close
|
||||
* even when interrupted. Idle injection uses a one-shot turn and durability
|
||||
* checkpoint. Disposal awaits idle checkpoints; flush failures report through
|
||||
* `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
|
||||
* @param content - the injected context content blocks.
|
||||
* @param options - source and durable model-hidden meta.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
* Re-open a turn on the current session log without a new prompt — the
|
||||
* recovery verb. After an `agent/idle` error, a consumer repairs (edits the
|
||||
* log, waits out a rate limit) and calls this; the machine immediately runs
|
||||
* another turn over the repaired history. Calling it synchronously from an
|
||||
* `agent/idle` listener is legal — the machine is already idle there.
|
||||
* @throws while a turn is running because there is nothing to retry yet.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
||||
return this.send(content, { ...options, target: 'next-step', wakeup: false })
|
||||
}
|
||||
retry(): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for machine exit rather than only the status transition. */
|
||||
whenIdle(): Promise<void>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -294,7 +181,7 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent left the registry; AgentLoop emits this after driver quiescence
|
||||
* An agent left the registry; AgentLoop emits this after machine quiescence
|
||||
* but before session detachment and scoped-registration unwind. Custom
|
||||
* registry users own their driver-ordering contract.
|
||||
* @param agent - the exact agent removed from the registry.
|
||||
@@ -303,7 +190,7 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
|
||||
* Agent activity changed (`idle` ⇄ `running`). `send()` does
|
||||
* not enter `running` synchronously; drive lifecycle from this event.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
@@ -312,39 +199,17 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* A detached, frozen item entered the agent's inbox (queued or steering
|
||||
* FIFO). Source defaults are already applied, so `message` holds the exact
|
||||
* accepted values. This is the enqueue-time live signal; the durable record
|
||||
* is the eventual `user/message`/`steering/message`. Injection
|
||||
* (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
|
||||
* @param agent - the agent whose inbox received the item.
|
||||
* @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).
|
||||
* Detached, frozen content entered the agent's inbox (prompt queue or
|
||||
* steering outbox). These are the exact values retained for the log.
|
||||
* @param agent - the agent whose inbox received the message.
|
||||
* @param content - the accepted content blocks retained by the inbox.
|
||||
* @param info - the accepted source, contexts, and steering classification.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void
|
||||
/**
|
||||
* The driver claimed one item out of the inbox: a queued item at a turn
|
||||
* boundary, or steering drained between steps. Fires after the item leaves
|
||||
* its FIFO and before it becomes a durable message.
|
||||
* @param agent - the agent whose inbox item was claimed.
|
||||
* @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
|
||||
/**
|
||||
* `cancel()` (without `keepInbox`) dropped pending inbox items without
|
||||
* delivering them. Fires once per effective clearing call with every
|
||||
* discarded item, after `agent/cancel-requested` and before the abort.
|
||||
* @param agent - the agent whose inbox was cleared.
|
||||
* @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void
|
||||
/**
|
||||
* Effective broad cancellation was requested, before queued/steering work
|
||||
* Effective broad cancellation was requested, before queued/outbox work
|
||||
* is cleared or the active turn is aborted. This observe-only notification
|
||||
* cannot veto cancellation; listener failures are contained.
|
||||
* @param agent - the agent whose current work is being cancelled.
|
||||
@@ -352,14 +217,12 @@ declare module 'cordis' {
|
||||
* 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) ----
|
||||
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentInterruptReason): void
|
||||
/**
|
||||
* The session lifecycle began, once before the first turn. Use
|
||||
* `agent.inject()` to seed model-facing context. This is a notification, not
|
||||
* a veto; disposal requested by a lifecycle owner is rechecked before the
|
||||
* driver starts.
|
||||
* machine starts.
|
||||
* @param agent - the agent whose session lifecycle began.
|
||||
* @param source - why the session started (fresh startup, resume, …).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -367,29 +230,12 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
|
||||
// Turn and step boundaries are durable session events, not agent events.
|
||||
|
||||
// ---- step/request extension seams (serial + waterfall) ----
|
||||
/**
|
||||
* Awaited serial checkpoint before `step/start`; appends land outside the
|
||||
* pending step and are included when the loop derives request history.
|
||||
* `signal` cancels listener work.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent opening the step.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the pending step number.
|
||||
* @param signal - the turn abort signal.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
// ---- the machine's extension seams ----
|
||||
/**
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message. Call `next()` for the unchanged default. A listener wrapping a
|
||||
* downstream `allow` must preserve its `content` and `additionalContexts`
|
||||
* unless it intentionally replaces them. The signal controls only this turn;
|
||||
* listeners may cooperate with it but must not retain it to control another
|
||||
* turn. Steering messages do not dispatch this event; they join an open turn
|
||||
* at a steering checkpoint.
|
||||
* message. Call `next()` for the unchanged default, including contexts
|
||||
* captured with the queued item. The signal controls only this turn;
|
||||
* listeners may cooperate with it but must not retain it for another turn.
|
||||
* @param agent - the agent whose turn claimed the message.
|
||||
* @param content - the claimed message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
@@ -399,100 +245,64 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Replace the frozen call configuration. Model-visible content must use
|
||||
* logged channels; this seam cannot mutate messages. Injection here joins
|
||||
* the next request because the current step boundary is already fixed.
|
||||
* Awaited serial checkpoint before EVERY request of a turn is built (the
|
||||
* first as well as each post-tools continuation). The single "between
|
||||
* steps" seam: inject context, steer, or edit the session log here — the
|
||||
* request's history derives from the log right after this settles.
|
||||
* @param agent - the agent about to send a request.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step number about to open.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Replace the frozen call configuration. `await next()` yields the config
|
||||
* the machine would use (agent options on the first request, the logged
|
||||
* header afterwards); return a replacement to switch. Model-visible
|
||||
* content must use logged channels; this seam cannot mutate messages.
|
||||
* @param agent - the agent making the model call.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param config - the config the loop would use (frozen); return a replacement to switch.
|
||||
* @param signal - the current turn's explicit abort signal; ambient
|
||||
* initiator identity does not imply liveness or cancellation authority.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Compose request-only messages placed before derived history. The frozen
|
||||
* result is computed once per loop instance, logged on its anchoring request
|
||||
* header, and reused so the provider prefix remains stable. Interrupted
|
||||
* composition is discarded. Composition precedes the first `agent/pre-step`
|
||||
* and request boundary, so listener appends join the current request.
|
||||
* Changing context belongs in history; contributors should prepend to
|
||||
* `await next()` to preserve registration order.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen seed; return an extended replacement.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
* @param agent - the agent that received the step's response.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step that produced the message.
|
||||
* @param message - the assistant message as assembled from the stream.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
* @mode compose
|
||||
*/
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Awaited serial checkpoint after the response, real or synthetic tool
|
||||
* results, injected context, and steering are durable but before `step/end`.
|
||||
* A cancelled tool batch reaches this checkpoint with an aborted signal.
|
||||
* @param agent - the agent whose step is settling.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the open step number.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/post-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Recover a model-request failure after its failed step has closed. `retry`
|
||||
* opens a new numbered step; `fail` preserves the original request error.
|
||||
* Call `next()` to delegate to the next recovery listener or the default.
|
||||
* @param agent - the agent whose request failed.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the failed step number.
|
||||
* @param error - the original model-request failure.
|
||||
* @param failure - serializable facts normalized at the final adapter boundary.
|
||||
* @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
|
||||
/**
|
||||
* Override whether the turn continues. The default continues after tool
|
||||
* calls or steering and stops otherwise; a continue reason becomes steering.
|
||||
* @param agent - the agent deciding whether to run another step.
|
||||
* @param turn - the turn being continued or stopped.
|
||||
* @param defaultDecision - what the loop would do absent an override.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
/**
|
||||
* Monotonic terminal-stop checkpoint after continuation and steering are
|
||||
* folded; a stop remains authoritative through turn close and flush:
|
||||
* steering queued in that window is discarded, while ordinary sends survive.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* The turn is about to close: the model owes no response (no live tool
|
||||
* calls, no fresh steering). Awaited before the boundary commits — a
|
||||
* listener that objects steers (`agent.steer(...)`) and the machine
|
||||
* re-reads its inbox: fresh steering runs another step, none closes the
|
||||
* turn. Data decides, so listener order cannot change the outcome. The
|
||||
* inverse control (stop a tool loop early) is data too: a tool result
|
||||
* carrying `concludesTurn` ends the turn at its step.
|
||||
* @param agent - the agent whose turn is at its stop boundary.
|
||||
* @param turn - the turn about to close.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
'agent/stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* One turn closed: its `turn/end` and durability flush are already
|
||||
* committed. `reason` says why — recovery consumers observe an `error`
|
||||
* reason, repair (edit the log, wait, resummon), and call
|
||||
* {@link Agent.retry}; UI consumers key turn-done presentation off it.
|
||||
* Emitted per turn, including cancelled and failed ones.
|
||||
* @param agent - the agent whose turn closed.
|
||||
* @param turn - the closed turn number.
|
||||
* @param reason - why the turn ended, with live error facts when it failed.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/idle'(this: Scoped<Agent>, agent: Agent, turn: number, reason: IdleReason): void
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
* A step or turn errored. The loop reports a failure here (plus the logger)
|
||||
* even when the error has no in-turn position for a session `error` event.
|
||||
* A step or turn errored. The machine reports a failure here (plus the
|
||||
* logger) even when the error has no in-turn position for a durable record.
|
||||
* @param agent - the agent whose turn errored.
|
||||
* @param turn - the turn in which the failure surfaced.
|
||||
* @param step - the step at which the failure surfaced.
|
||||
|
||||
@@ -307,6 +307,8 @@ export interface ToolRunContext extends ToolExecution {
|
||||
* are emitted in call order.
|
||||
*/
|
||||
deferContext(context: HookContext): void
|
||||
/** Mark a successful final result as terminal for the current agent turn. */
|
||||
concludeTurn(): void
|
||||
}
|
||||
|
||||
/** Registry-owned live execution object; public pipeline views stay readonly. */
|
||||
@@ -439,6 +441,8 @@ export interface ToolExecutionSuccess {
|
||||
readonly error?: never
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: HookContext[]
|
||||
/** The agent loop stops after committing this successful result batch. */
|
||||
readonly concludesTurn?: true
|
||||
}
|
||||
|
||||
/** Failed canonical tool execution; failures never carry a successful value. */
|
||||
@@ -449,6 +453,7 @@ export interface ToolExecutionFailure {
|
||||
readonly content: ContentBlock[]
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: HookContext[]
|
||||
readonly concludesTurn?: never
|
||||
}
|
||||
|
||||
/** The discriminated, execution-local outcome of one tool call. */
|
||||
@@ -648,6 +653,10 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
|
||||
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
|
||||
/** Successful executions whose tool body declared the current turn complete. */
|
||||
private concludingExecutions = new WeakSet<ToolExecution>()
|
||||
/** Enclosing transport tokens marked terminal by a successful nested call. */
|
||||
private concludingParents = new Set<ToolExecutionToken>()
|
||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||
/** Definition-owned final content transform snapshotted before policy begins. */
|
||||
@@ -969,6 +978,8 @@ export class ToolRegistry extends Service {
|
||||
const signal = exec.signal
|
||||
const definition = this.get(name, agent)
|
||||
const finalizeContent = definition?.finalizeContent?.bind(definition)
|
||||
const concludingExecutions = this.concludingExecutions
|
||||
const concludingParents = this.concludingParents
|
||||
const base = {
|
||||
token,
|
||||
callId,
|
||||
@@ -979,6 +990,10 @@ export class ToolRegistry extends Service {
|
||||
deferContext(context: HookContext): void {
|
||||
deferredContexts.push(context)
|
||||
},
|
||||
concludeTurn(): void {
|
||||
if (parent === undefined) concludingExecutions.add(this as unknown as ToolExecution)
|
||||
else concludingParents.add(parent)
|
||||
},
|
||||
}
|
||||
try {
|
||||
const detached = snapshotJsonValue(exec.arguments)
|
||||
@@ -1192,6 +1207,7 @@ export class ToolRegistry extends Service {
|
||||
finalResult = this.materializeFinalResult(toolErrorResult(error))
|
||||
}
|
||||
this.notifyResult(exec, finalResult)
|
||||
this.concludingParents.delete(exec.token)
|
||||
return finalResult
|
||||
}
|
||||
|
||||
@@ -1362,11 +1378,13 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
||||
}
|
||||
const concludesTurn = this.concludingExecutions.has(exec) || this.concludingParents.has(exec.token)
|
||||
return this.markCanonical(exec, this.materializeFinalResult({
|
||||
isError: false,
|
||||
value,
|
||||
content,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
...concludesTurn ? { concludesTurn: true as const } : {},
|
||||
}) as ToolExecutionSuccess)
|
||||
}
|
||||
|
||||
@@ -1397,6 +1415,7 @@ export class ToolRegistry extends Service {
|
||||
content: result.content,
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
||||
...result.concludesTurn === true ? { concludesTurn: true as const } : {},
|
||||
}
|
||||
if (result.isError) {
|
||||
return materializePresentation({ isError: true as const, error: result.error, ...presentation })
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
goalToolExecution,
|
||||
requireDirectHuman,
|
||||
} from './authority.ts'
|
||||
import type { GoalToolExecution } from './authority.ts'
|
||||
|
||||
export const name = 'tool-goal'
|
||||
export const inject = ['agents', 'goals', 'tools', 'systemPrompt']
|
||||
@@ -174,30 +172,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen
|
||||
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
|
||||
}
|
||||
|
||||
/** Remember whether one autonomous terminal report should stop this turn. */
|
||||
function observeMutation(
|
||||
terminalTurns: WeakMap<Agent, number>,
|
||||
execution: GoalToolExecution,
|
||||
autonomousTerminal: boolean,
|
||||
): void {
|
||||
if (!autonomousTerminal) {
|
||||
terminalTurns.delete(execution.agent)
|
||||
return
|
||||
}
|
||||
terminalTurns.set(execution.agent, execution.start.data.turn)
|
||||
}
|
||||
|
||||
/** Register the three Codex-shaped goal tools and their shared policy section. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = resolveConfig(config)
|
||||
// A stale entry cannot match a later loop turn because turn numbers increase
|
||||
// monotonically within the agent's fixed session.
|
||||
const terminalTurns = new WeakMap<Agent, number>()
|
||||
ctx.on('agent/turn-stop', (agent, turn) => {
|
||||
if (terminalTurns.get(agent) !== turn) return undefined
|
||||
terminalTurns.delete(agent)
|
||||
return { action: 'stop' }
|
||||
})
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:goal',
|
||||
order: 114,
|
||||
@@ -238,7 +215,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
objective: args.objective,
|
||||
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
|
||||
})
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve(goalValue(goal))
|
||||
},
|
||||
presentCall: args => present('Create goal', 'other', args.objective),
|
||||
@@ -280,7 +256,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
|
||||
}
|
||||
const goal = ctx.goals.edit(execution.agent, ref, replacements)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve(goalValue(goal))
|
||||
}
|
||||
if (args.action === 'pause' || args.action === 'resume') {
|
||||
@@ -294,7 +269,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const goal = args.action === 'pause'
|
||||
? ctx.goals.pause(execution.agent, ref)
|
||||
: ctx.goals.resume(execution.agent, ref)
|
||||
observeMutation(terminalTurns, execution, false)
|
||||
return Promise.resolve(goalValue(goal))
|
||||
}
|
||||
const authority = completionAuthority(ctx, execution)
|
||||
@@ -325,7 +299,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
code: 'model-reported',
|
||||
message: args.blocked_reason as string,
|
||||
})
|
||||
observeMutation(terminalTurns, execution, authority.kind === 'goal-round')
|
||||
if (authority.kind === 'goal-round') exec.concludeTurn()
|
||||
return Promise.resolve(goalValue(goal))
|
||||
},
|
||||
presentCall: args => present(
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
* contribution is ordinary reconstructed request state.
|
||||
*
|
||||
* Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also
|
||||
* waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard
|
||||
* then prevent later listeners or calls from reopening a completed structured run.
|
||||
* waits for the enclosing `run_code` result. The terminal result marker and monotonic tool
|
||||
* guard prevent later calls from reopening a completed structured run.
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution, ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The model-facing tool name a structured child must call to finish. */
|
||||
@@ -83,7 +82,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
},
|
||||
render: () => [{ type: 'text', text: 'Structured output recorded.' }],
|
||||
},
|
||||
execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> {
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<{ recorded: true }> {
|
||||
const violations = validateJsonSchemaValue(schema, args)
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
// within the same turn, exactly like a schema-validated defineTool call.
|
||||
@@ -92,6 +91,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
// waterfalls may still turn the success into an error. ToolRegistry has
|
||||
// already frozen model-bound arguments at the actual input boundary.
|
||||
staged.set(exec, { value: args })
|
||||
exec.concludeTurn()
|
||||
return Promise.resolve({ recorded: true })
|
||||
},
|
||||
})
|
||||
@@ -102,13 +102,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
})
|
||||
|
||||
// Stop the child's turn once its output is captured. This monotonic serial
|
||||
// checkpoint runs after the ordinary continuation waterfall, its reason,
|
||||
// and late-steering folding, so no ordering trick can resume a finished run.
|
||||
childCtx.on('agent/turn-stop', function (this: unknown, _agent, _turn, _signal): ContinuationStop | undefined {
|
||||
return captured === undefined ? undefined : { action: 'stop' }
|
||||
})
|
||||
|
||||
// Terminal WITHIN the step. Guards run after the whole pre-execute
|
||||
// waterfall and compose monotonically (deny or abstain, never allow), so a
|
||||
// later prepended listener cannot resurrect dispatch. Calls that precede
|
||||
|
||||
@@ -1088,7 +1088,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// produces an error stop reason).
|
||||
const stopReason = await new Promise<StopReason>((resolve, reject) => {
|
||||
rec.inflight = { resolve, reject, turn: undefined }
|
||||
rec.agent.send(preparedContent, { contexts: preparedContexts })
|
||||
rec.agent.send(preparedContent, { source: { kind: 'user' }, contexts: preparedContexts })
|
||||
})
|
||||
return { stopReason }
|
||||
},
|
||||
|
||||
@@ -2522,9 +2522,9 @@ export function createTuiChat(
|
||||
if (agent.status === 'disposed') {
|
||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||
} else if (agent.status === 'running') {
|
||||
agent.steer(content, { contexts })
|
||||
agent.steer(content, { source: { kind: 'user' }, contexts })
|
||||
} else {
|
||||
agent.send(content, { contexts })
|
||||
agent.send(content, { source: { kind: 'user' }, contexts })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user