fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -8,9 +8,9 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`.
|
||||
Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. The capabilities reject competing `register`/`enter`/`prepare`/`create` calls, so setup cannot publish the factory objects or same-id replacements. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. The concrete agent owns runtime-pinned `id`, frozen detached `options`, `session`, and `ctx` bindings. Load/setup rejection or owner unload publishes nothing; partial creation announcements are paired during rollback. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, which contains sync/async listener failures per observer; per-step assembly goes through `assembleContextFor(agent)`; the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox } from './inbox.ts'
|
||||
@@ -65,6 +65,26 @@ export function prepareReactLoopAgent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the concrete agent's scope context exactly once. Construction and
|
||||
* scope minting are mutually referential (the scope key is the agent), so the
|
||||
* factory performs this one post-construction binding before setup receives
|
||||
* the unpublished agent. The runtime slot is non-writable/non-configurable;
|
||||
* TypeScript `readonly` alone would still let JavaScript redirect later
|
||||
* registrations to another context.
|
||||
* @param agent - the unpublished concrete agent to bind.
|
||||
* @param ctx - its fully extended agent scope context.
|
||||
*/
|
||||
export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void {
|
||||
if (Object.hasOwn(agent, 'ctx')) throw new Error(`agent "${agent.id}" context is already bound`)
|
||||
Object.defineProperty(agent, 'ctx', {
|
||||
value: ctx,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
*
|
||||
@@ -84,18 +104,7 @@ export class ReactLoopAgent implements Agent {
|
||||
* context are mutually referential (the scope is keyed BY this agent), so
|
||||
* neither can exist strictly before the other.
|
||||
*/
|
||||
ctx!: Context
|
||||
|
||||
/**
|
||||
* The dispatch carrier for this agent's own emits (`agent/status`,
|
||||
* `agent/queued`, `agent/error`): keyed by the agent, base = the agent
|
||||
* (listener `this` is the agent). Built lazily because it is self-referential.
|
||||
*/
|
||||
private get carrier(): Scoped<Agent> {
|
||||
return (this.#carrier ??= scopeTarget(this, this))
|
||||
}
|
||||
|
||||
#carrier: Scoped<Agent> | undefined
|
||||
declare readonly ctx: Context
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
private currentAbort: AbortController | undefined
|
||||
@@ -143,6 +152,16 @@ export class ReactLoopAgent implements Agent {
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
) {
|
||||
const acceptedOptions = deepFreeze(structuredClone(options))
|
||||
// Pin the public ownership/identity bindings in the runtime object. A
|
||||
// JavaScript caller can otherwise replace TS-readonly parameter properties
|
||||
// after publication and split the registry, driver, session, and model
|
||||
// configuration into different worlds.
|
||||
Object.defineProperties(this, {
|
||||
id: { value: id, enumerable: true, writable: false, configurable: false },
|
||||
options: { value: acceptedOptions, enumerable: true, writable: false, configurable: false },
|
||||
session: { value: session, enumerable: true, writable: false, configurable: false },
|
||||
})
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.disposed = promise
|
||||
this.resolveDisposed = resolve
|
||||
@@ -161,11 +180,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
|
||||
// not hang on one bad listener).
|
||||
if (status !== 'running') this.settleIdleWaiters()
|
||||
try {
|
||||
this.loopCtx.emit(this.carrier, 'agent/status', this, status)
|
||||
} catch (error: unknown) {
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
|
||||
}
|
||||
agentEvents(this.loopCtx, this).emit('agent/status', status)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +209,7 @@ export class ReactLoopAgent implements Agent {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
const source = this.resolveSource(options)
|
||||
this.#inbox.enqueue({ content, source })
|
||||
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: false })
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
@@ -203,7 +218,7 @@ export class ReactLoopAgent implements Agent {
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const source = this.resolveSource(options)
|
||||
this.#inbox.steer({ content, source })
|
||||
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: true })
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
@@ -269,14 +284,10 @@ export class ReactLoopAgent implements Agent {
|
||||
if (turnRecorded) {
|
||||
// Through the store's flush (the carrier owner), never a raw parallel.
|
||||
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`)
|
||||
try {
|
||||
this.loopCtx.emit(this.carrier, 'agent/error', this, turn, 0, err)
|
||||
} catch {
|
||||
// contained: the failure is already logged; a throwing agent/error
|
||||
// listener must not escape this fire-and-forget catch.
|
||||
}
|
||||
const rendered = renderThrown(error)
|
||||
const err = error instanceof Error ? error : new Error(rendered)
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
|
||||
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
|
||||
})
|
||||
this.pendingIdleFlushes.add(flush)
|
||||
// Attach the same retirement callback to both settlement arms so even a
|
||||
@@ -393,11 +404,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// setStatus refuses transitions out of 'disposed', so emit directly —
|
||||
// 'disposed' is part of the agent/status contract. Guarded: a throwing
|
||||
// listener must not break the disposal chain.
|
||||
try {
|
||||
this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed')
|
||||
} catch {
|
||||
// listener error during disposal — nothing safe left to do with it
|
||||
}
|
||||
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
|
||||
}
|
||||
// An unexpected driver rejection must not skip registry/session/scope
|
||||
// cleanup. The normal loop contains turn failures itself; allSettled is the
|
||||
@@ -414,3 +421,12 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render an arbitrary thrown value without allowing coercion to throw again. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
} catch {
|
||||
return '<unrenderable thrown value>'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,24 @@ 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 { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, AgentRegistrationReservation, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionRegistrationReservation } 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 { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
|
||||
|
||||
export { ReactLoopAgent } from './agent.ts'
|
||||
|
||||
/** Both unpublished identity capabilities held by one factory transaction. */
|
||||
interface RegistrationReservations {
|
||||
agent: AgentRegistrationReservation
|
||||
session: SessionRegistrationReservation
|
||||
release(): void
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
@@ -71,10 +78,6 @@ export interface Config {
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
/** IDs held by unpublished async creation transactions. */
|
||||
private pendingAgentIds = new Set<AgentId>()
|
||||
private pendingSessionIds = new Set<SessionId>()
|
||||
|
||||
// The schema validates plain strings (cordis.yml config values are untyped at
|
||||
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
|
||||
// because the config format is the boundary where an id enters. The brand is a
|
||||
@@ -153,14 +156,19 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @returns the running agent, owned by the calling fiber (no handle).
|
||||
*/
|
||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
||||
this.assertAgentIdFree(id)
|
||||
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
|
||||
const reservations = this.reserve(id, sessionId)
|
||||
// Config/programmatic path: prepare the session and let start() fold its
|
||||
// lifecycle into the agent's composite effect (so a fiber unload tears the
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
|
||||
const { agent } = this.start(id, options, session, 'startup')
|
||||
return agent
|
||||
try {
|
||||
const session = reservations.session.prepare({ meta })
|
||||
const { agent } = this.start(id, options, session, 'startup', reservations)
|
||||
return agent
|
||||
} finally {
|
||||
reservations.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,16 +196,16 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const seed = options.seed
|
||||
const meta = options.meta
|
||||
const release = this.reserve(agentId, sessionId)
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
try {
|
||||
const session = this.ctx.sessions.prepare(sessionId, {
|
||||
const session = reservations.session.prepare({
|
||||
...seed !== undefined ? { seed } : {},
|
||||
...meta !== undefined ? { meta } : {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume.
|
||||
return await this.startOwned(agentId, agentOptions, session, 'startup', setup)
|
||||
return await this.startOwned(agentId, agentOptions, session, 'startup', reservations, setup)
|
||||
} finally {
|
||||
release()
|
||||
reservations.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +281,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
return transactionSettled
|
||||
}, `agentLoop.resumeLoad(${agentId})`)
|
||||
try {
|
||||
const release = this.reserve(agentId, sessionId)
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
try {
|
||||
const loadTask = persistence.load(sessionId)
|
||||
const { meta, events } = await Promise.race([
|
||||
@@ -292,7 +300,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// An out-of-band direct registry/session insertion can still race this
|
||||
// service's reservation, so the public enter primitives re-check exact
|
||||
// liveness at publication.
|
||||
const session = this.ctx.sessions.prepare(sessionId, {
|
||||
const session = reservations.session.prepare({
|
||||
seed: events,
|
||||
meta: {
|
||||
createdAt,
|
||||
@@ -305,12 +313,12 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// effect before it reaches its first setup await. Only then disarm the
|
||||
// load sentinel: ownership passes directly from one effect to the other
|
||||
// with no disposal gap.
|
||||
const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup)
|
||||
const starting = this.startOwned(agentId, agentOptions, session, 'resume', reservations, setup)
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
return await starting
|
||||
} finally {
|
||||
release()
|
||||
reservations.release()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
@@ -327,29 +335,24 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a duplicate agent id BEFORE the session is entered into the store, so
|
||||
* a failed factory call never leaves an orphaned live session (and lazy
|
||||
* persistence state) behind. `register()` enforces the same uniqueness, but
|
||||
* only after the session has already entered the store.
|
||||
*/
|
||||
private assertAgentIdFree(id: AgentId): void {
|
||||
if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserve both public identities for one unpublished async transaction. */
|
||||
private reserve(agentId: AgentId, sessionId: SessionId): () => void {
|
||||
this.assertAgentIdFree(agentId)
|
||||
if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) {
|
||||
throw new Error(`session "${sessionId}" already exists`)
|
||||
}
|
||||
this.pendingAgentIds.add(agentId)
|
||||
this.pendingSessionIds.add(sessionId)
|
||||
return () => {
|
||||
this.pendingAgentIds.delete(agentId)
|
||||
this.pendingSessionIds.delete(sessionId)
|
||||
/** Reserve both public identities in their owning registries. */
|
||||
private reserve(agentId: AgentId, sessionId: SessionId): RegistrationReservations {
|
||||
const agent = this.ctx.agents.reserve(agentId)
|
||||
try {
|
||||
const session = this.ctx.sessions.reserve(sessionId)
|
||||
return {
|
||||
agent,
|
||||
session,
|
||||
release() {
|
||||
// Both owner capabilities are independently idempotent, so the
|
||||
// composite needs no second state machine of its own.
|
||||
session.release()
|
||||
agent.release()
|
||||
},
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
agent.release()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +364,12 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `active`, unwinds the scope, and wins the race without any late Cordis
|
||||
* effect collection.
|
||||
*/
|
||||
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
|
||||
private prepareLifecycle(
|
||||
id: AgentId,
|
||||
options: AgentOptions,
|
||||
session: Session,
|
||||
reservations: RegistrationReservations,
|
||||
): {
|
||||
agent: ReactLoopAgent
|
||||
active: () => boolean
|
||||
deactivated: Promise<void>
|
||||
@@ -378,7 +386,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
const { agent } = driver
|
||||
const scope: Scope = createScope(this.ctx, agent)
|
||||
agent.ctx = scope.ctx.extend({ agent })
|
||||
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
|
||||
|
||||
let active = true
|
||||
let detachSession: (() => void) | undefined
|
||||
@@ -422,19 +430,15 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const publish = (source: SessionStartSource): void => {
|
||||
// Publication is one synchronous, rollback-covered sequence. Setup has
|
||||
// already completed, so its scoped listeners observe both announcements.
|
||||
detachSession = agent.ctx.sessions.enter(session)
|
||||
detachAgent = this.ctx.agents.enter(agent)
|
||||
detachSession = agent.ctx.sessions.enter(session, reservations.session)
|
||||
detachAgent = this.ctx.agents.enter(agent, reservations.agent)
|
||||
this.ctx.sessions.announce(session)
|
||||
this.ctx.agents.announce(agent)
|
||||
// Setup is over and both entries are live. Open the driving surface just
|
||||
// before session-start so its listeners retain their supported ability to
|
||||
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||
driver.enableDrive()
|
||||
try {
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
||||
}
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
stop = driver.startDriver()
|
||||
}
|
||||
|
||||
@@ -453,9 +457,13 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
|
||||
/** Publish a no-setup config agent synchronously. */
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
id: AgentId,
|
||||
options: AgentOptions,
|
||||
session: Session,
|
||||
source: SessionStartSource,
|
||||
reservations: RegistrationReservations,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session)
|
||||
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
|
||||
try {
|
||||
lifecycle.publish(source)
|
||||
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
|
||||
@@ -485,9 +493,10 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
*/
|
||||
private async startOwned(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
reservations: RegistrationReservations,
|
||||
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||
): Promise<AgentHandle> {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session)
|
||||
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
|
||||
try {
|
||||
// The owner-disposal branch makes a never-settling setup unable to hold
|
||||
// the transaction or its ID reservations forever. Promise.race installs
|
||||
|
||||
@@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
@@ -49,6 +49,34 @@ function send(agent: ReactLoopAgent, text: string) {
|
||||
}
|
||||
|
||||
describe('ReactLoopAgent', () => {
|
||||
it('owns immutable runtime bindings for id, options, session, and scoped context', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('unused')]))
|
||||
const options = { model: 'mock' }
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
|
||||
const acceptedSession = agent.session
|
||||
const acceptedContext = agent.ctx
|
||||
|
||||
options.model = 'caller-mutated'
|
||||
expect(agent.options).toEqual({ model: 'mock' })
|
||||
expect(Object.isFrozen(agent.options)).toBe(true)
|
||||
expect(Reflect.set(agent, 'id', AgentId('redirected'))).toBe(false)
|
||||
expect(Reflect.set(agent, 'options', { model: 'other' })).toBe(false)
|
||||
expect(Reflect.set(agent, 'session', ctx.sessions.create(SessionId('other')))).toBe(false)
|
||||
expect(Reflect.set(agent, 'ctx', new Context())).toBe(false)
|
||||
expect(agent.id).toBe('owned-bindings')
|
||||
expect(agent.session).toBe(acceptedSession)
|
||||
expect(agent.ctx).toBe(acceptedContext)
|
||||
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||
for (const name of ['id', 'options', 'session', 'ctx']) {
|
||||
expect(Object.getOwnPropertyDescriptor(agent, name)).toMatchObject({
|
||||
configurable: false,
|
||||
writable: false,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('send() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -197,6 +225,23 @@ describe('ReactLoopAgent', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() safely renders a hostile non-Error flush failure', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const hostile = { [Symbol.toPrimitive]() { throw new Error('no coercion') } }
|
||||
ctx.on('session/flush', () => { throw hostile })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('hostile-flush'), { model: 'mock' })
|
||||
const errors: string[] = []
|
||||
ctx.on('agent/error', (_a, _turn, _step, error) => void errors.push(error.message))
|
||||
|
||||
agent.inject([{ type: 'text', text: 'notice' }])
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(errors).toEqual(['<unrenderable thrown value>'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('<unrenderable thrown value>'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -416,7 +461,7 @@ describe('ReactLoopAgent', () => {
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
@@ -434,7 +479,7 @@ describe('ReactLoopAgent', () => {
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,6 +197,35 @@ describe('agent scope lifecycle', () => {
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('makes setup-time publication structurally impossible through public stores', async () => {
|
||||
const ctx = await harness()
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/created', () => void lifecycle.push('session'))
|
||||
ctx.on('agent/created', () => void lifecycle.push('agent'))
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('guarded-publication'),
|
||||
sessionId: SessionId('guarded-publication-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
const agent = agentCtx.agent!
|
||||
expect(() => agentCtx.agents.enter(agent)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.agents.register(agent)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.sessions.enter(agent.session)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.sessions.prepare(agent.session.id)).toThrow(/reserved for unpublished creation/)
|
||||
expect(() => agentCtx.sessions.create(agent.session.id)).toThrow(/reserved for unpublished creation/)
|
||||
expect(lifecycle).toEqual([])
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
expect(ctx.sessions.get(agent.session.id)).toBeUndefined()
|
||||
},
|
||||
})
|
||||
|
||||
expect(lifecycle).toEqual(['session', 'agent'])
|
||||
expect(ctx.agents.get(handle.agent.id)).toBe(handle.agent)
|
||||
expect(ctx.sessions.get(handle.agent.session.id)).toBe(handle.agent.session)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('structurally rejects every driving verb during setup', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
@@ -357,6 +386,33 @@ describe('agent scope lifecycle', () => {
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('pairs session and agent announcements when agent creation aborts publication', async () => {
|
||||
const ctx = await harness()
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
|
||||
ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
|
||||
ctx.on('agent/created', (agent) => {
|
||||
lifecycle.push(`agent-created:${agent.id}`)
|
||||
throw new Error('agent observer failed')
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('partial-agent'),
|
||||
sessionId: SessionId('partial-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow('agent observer failed')
|
||||
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created:partial-session',
|
||||
'agent-created:partial-agent',
|
||||
'agent-disposed:partial-agent',
|
||||
'session-disposed:partial-session',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the synchronous config helper rolls back when publication throws', async () => {
|
||||
const ctx = await harness()
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
|
||||
Reference in New Issue
Block a user