mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
630 lines
24 KiB
TypeScript
630 lines
24 KiB
TypeScript
/**
|
|
* Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
|
|
* through the agent/session registries, and owns their ordered teardown.
|
|
*
|
|
* @module @deepseek-ai/dsh-agent-loop
|
|
*/
|
|
|
|
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 type {
|
|
Agent,
|
|
AgentFactory,
|
|
AgentHandle,
|
|
AgentOptions,
|
|
CreateAgentOptions,
|
|
ResumeAgentOptions,
|
|
SessionStartSource,
|
|
} from '@deepseek-ai/dsh-agent'
|
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
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 { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
|
|
|
|
/** Fiber states that cannot own or serve a new lifecycle. */
|
|
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
|
FiberState.UNLOADING,
|
|
FiberState.DISPOSED,
|
|
FiberState.FAILED,
|
|
])
|
|
|
|
/** Factory-level ownership of every preparing or live transaction. */
|
|
class FactoryOwnership {
|
|
private accepting = true
|
|
private readonly inactive = Promise.withResolvers<void>()
|
|
private transactions = new Set<AgentCreationTransaction>()
|
|
private startupTasks = new Set<Promise<void>>()
|
|
|
|
constructor(private readonly fiber: Context['fiber']) {}
|
|
|
|
isActive(): boolean {
|
|
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
|
|
}
|
|
|
|
track(transaction: AgentCreationTransaction): () => void {
|
|
this.transactions.add(transaction)
|
|
return () => { this.transactions.delete(transaction) }
|
|
}
|
|
|
|
/** Join config startup work that begins before an agent transaction exists. */
|
|
trackStartup(task: Promise<void>): void {
|
|
this.startupTasks.add(task)
|
|
const forget = () => { this.startupTasks.delete(task) }
|
|
void task.then(forget, forget)
|
|
}
|
|
|
|
/** Resolve `task`, or stop waiting when factory teardown begins. */
|
|
async waitWhileActive(task: Promise<void>): Promise<void> {
|
|
await Promise.race([task, this.inactive.promise])
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
this.accepting = false
|
|
this.inactive.resolve()
|
|
const reason = new Error('agent loop is not active')
|
|
await Promise.all([
|
|
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
|
...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 })
|
|
}
|
|
|
|
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
|
|
function resolveMaxParallelToolCalls(value: number | undefined): number {
|
|
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
|
|
if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
|
|
throw new Error('maxParallelToolCalls must be a positive integer')
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
agentLoop: AgentLoop
|
|
}
|
|
interface Events {
|
|
/**
|
|
* A declarative agent entry failed before it could publish a live agent.
|
|
* Consumers that buffer work for the configured identity use this
|
|
* transient signal to reject that work instead of waiting forever. Normal
|
|
* factory teardown suppresses failures from the cancelled startup attempt.
|
|
* @param sessionId - exact shared agent/session identity that failed startup.
|
|
* @param error - persistence, setup, or publication failure.
|
|
* @mode emit
|
|
*/
|
|
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
|
|
}
|
|
}
|
|
|
|
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
|
|
|
/** Agent-loop plugin configuration. */
|
|
export interface Config {
|
|
/**
|
|
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
|
|
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
|
*/
|
|
maxParallelToolCalls?: number
|
|
/** Agents created or resumed at plugin startup. */
|
|
agents: (AgentOptions & {
|
|
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
|
id: string
|
|
/** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
|
|
sessionId?: SessionId
|
|
/** Optional workspace for a fresh session. */
|
|
cwd?: string
|
|
/** Persisted session to resume instead of creating a fresh session. */
|
|
resumeSessionId?: SessionId
|
|
})[]
|
|
}
|
|
|
|
/** Reject self-contained identity conflicts before any configured agent starts. */
|
|
function validateConfiguredAgents(agents: Config['agents']): void {
|
|
const exactIdentities = new Map<SessionId, string>()
|
|
for (const { id, sessionId, resumeSessionId } of agents) {
|
|
const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
|
|
if (sessionId !== undefined && hasResumeId) {
|
|
throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
|
|
}
|
|
const exactIdentity = hasResumeId ? resumeSessionId : sessionId
|
|
if (exactIdentity === undefined) continue
|
|
const firstId = exactIdentities.get(exactIdentity)
|
|
if (firstId !== undefined) {
|
|
throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
|
|
}
|
|
exactIdentities.set(exactIdentity, id)
|
|
}
|
|
}
|
|
|
|
/** Concrete agent factory and driver service. */
|
|
export class AgentLoop extends Service implements AgentFactory {
|
|
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
|
|
|
/** Runtime schema for declarative agents. */
|
|
static Config = z.object({
|
|
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
|
|
agents: z.array(z.object({
|
|
id: z.string().required(),
|
|
sessionId: z.string().min(1),
|
|
provider: z.string(),
|
|
model: z.string(),
|
|
cwd: z.string(),
|
|
resumeSessionId: z.string(),
|
|
})).default([]),
|
|
}) as unknown as z<Config>
|
|
|
|
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) {
|
|
super(ctx, 'agentLoop')
|
|
validateConfiguredAgents(config.agents)
|
|
this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
|
|
this.ownership = new FactoryOwnership(ctx.fiber)
|
|
this.runtime = { ctx }
|
|
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
|
|
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
|
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
|
|
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) {
|
|
const meta = cwd === undefined ? {} : { cwd }
|
|
if (resumeSessionId === undefined || resumeSessionId === '') {
|
|
const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
|
|
const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
|
|
if (persistence === undefined) {
|
|
this.create(configuredId, options, meta)
|
|
} else {
|
|
const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
|
|
this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
|
|
})
|
|
this.ownership.trackStartup(startup)
|
|
}
|
|
continue
|
|
}
|
|
ctx.effect(() => {
|
|
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
|
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
|
resumeSessionId,
|
|
agentOptions: options,
|
|
}).catch((error: unknown) => {
|
|
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
|
|
})
|
|
})
|
|
return fiber.dispose
|
|
}, `agentLoop.resume(${id})`)
|
|
}
|
|
}
|
|
|
|
/** Report a contained declarative-start failure to identity-bound consumers. */
|
|
private reportConfiguredStartupFailure(
|
|
configId: string,
|
|
action: 'restore' | 'resume',
|
|
sessionId: SessionId,
|
|
error: unknown,
|
|
): void {
|
|
if (!this.ownership.isActive()) return
|
|
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
|
|
const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error]
|
|
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
|
try {
|
|
const returned: unknown = callback(...args)
|
|
void Promise.resolve(returned).catch((listenerError: unknown) => {
|
|
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
|
|
})
|
|
} catch (listenerError: unknown) {
|
|
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Restore a materialized exact config identity on remount, or create it on first use. */
|
|
private async restoreOrCreateConfigured(
|
|
ownerCtx: Context,
|
|
persistence: SessionPersistence,
|
|
sessionId: SessionId,
|
|
agentOptions: AgentOptions,
|
|
meta: Pick<SessionHeader, 'cwd'>,
|
|
): Promise<void> {
|
|
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
|
|
if (!this.ownership.isActive()) return
|
|
const exists = (await persistence.list()).some(header => header.id === sessionId)
|
|
if (!this.ownership.isActive()) return
|
|
if (exists) {
|
|
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
|
|
return
|
|
}
|
|
this.create(sessionId, agentOptions, meta)
|
|
}
|
|
|
|
/** Wait for an already-disposed 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
|
|
|
|
const released = Promise.withResolvers<void>()
|
|
const checkReleased = (): void => {
|
|
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
|
|
released.resolve()
|
|
}
|
|
}
|
|
const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased)
|
|
const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
|
|
try {
|
|
checkReleased()
|
|
await this.ownership.waitWhileActive(released.promise)
|
|
} finally {
|
|
disposeAgentListener()
|
|
disposeSessionListener()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an agent and session under one caller-supplied identity, owned by
|
|
* the accessing fiber. Constructor-driven config calls mint a fresh combined
|
|
* id before entering this boundary.
|
|
* @param id - shared agent/session identity.
|
|
* @param options - concrete loop options.
|
|
* @param meta - optional fresh-session workspace metadata.
|
|
* @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)
|
|
try {
|
|
const session = loopCtx.sessions.prepare(id, { meta })
|
|
const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
|
|
transaction.publish('startup')
|
|
return agent
|
|
} catch (error: unknown) {
|
|
void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
|
|
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 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()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resume an owned agent from the configured persistence service.
|
|
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
|
|
* @param options - persisted identity, loop options, setup, and cancellation.
|
|
* @returns the published handle.
|
|
*/
|
|
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
|
|
const persistence = this.runtime.ctx.get('sessionPersistence')
|
|
if (persistence === undefined) {
|
|
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
|
}
|
|
return this.resumeWith(ownerCtx, persistence, options)
|
|
}
|
|
|
|
/** Resume through an explicit persistence handle used by the deferred config path. */
|
|
private async 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, {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
export default AgentLoop
|