Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/trim-ai-prose
This commit is contained in:
@@ -10,7 +10,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
|
||||
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => Promise<void> | void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||
- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
|
||||
- `ctx.agents.get(id: AgentId): Agent | undefined`
|
||||
- `ctx.agents.list(): Agent[]`
|
||||
@@ -19,7 +19,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
|
||||
|
||||
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
|
||||
|
||||
- `ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
|
||||
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
|
||||
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ export class AgentRegistry extends Service {
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
setFactory(factory: AgentFactory): () => Promise<void> | void {
|
||||
setFactory(factory: AgentFactory): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
|
||||
// Avoid stacking two Cordis shadow layers when a caller passes a Service
|
||||
@@ -242,6 +242,7 @@ export class AgentRegistry extends Service {
|
||||
// caller's composite effect can yield it for in-order teardown; the
|
||||
// loop's constructor effect returns it directly, identity-nesting the
|
||||
// registration under that effect.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
@@ -305,11 +306,12 @@ export class AgentRegistry extends Service {
|
||||
* owner unload, unregistering the agent (and emitting `agent/disposed`)
|
||||
* while its final turn is still draining.
|
||||
*/
|
||||
register(agent: Agent): () => Promise<void> | void {
|
||||
register(agent: Agent): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||
yield this.enter(agent)
|
||||
this.announce(agent)
|
||||
}.bind(this), 'agents.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
|
||||
@@ -575,8 +575,7 @@ declare module 'cordis' {
|
||||
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
|
||||
* to abstain. Terminal stop is monotonic: listener order and steering
|
||||
* cannot resume the turn, and pending steering is discarded rather than
|
||||
* becoming another step or turn. A malformed non-undefined result fails
|
||||
* the turn closed.
|
||||
* becoming another step or turn.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
@@ -586,7 +585,7 @@ declare module 'cordis' {
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
const id = AgentId(rawId)
|
||||
@@ -21,6 +22,14 @@ function stubAgent(rawId: string): Agent {
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
it('keeps terminal stop decisions synchronous', () => {
|
||||
type TurnStopListener = Events['agent/turn-stop']
|
||||
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
|
||||
|
||||
expectTypeOf<AsyncTurnStopListener>().not.toExtend<TurnStopListener>()
|
||||
expectTypeOf<ReturnType<TurnStopListener>>().toEqualTypeOf<ContinuationStop | undefined>()
|
||||
})
|
||||
|
||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -34,7 +43,7 @@ describe('AgentRegistry', () => {
|
||||
expect(ctx.agents.list()).toEqual([agent])
|
||||
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
||||
|
||||
await dispose()
|
||||
dispose()
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
||||
})
|
||||
@@ -65,7 +74,7 @@ describe('AgentRegistry', () => {
|
||||
|
||||
const dispose = ctx.agents.register(stubAgent('contained'))
|
||||
await Promise.resolve()
|
||||
await dispose()
|
||||
dispose()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(heard).toEqual(['contained'])
|
||||
|
||||
Reference in New Issue
Block a user