refactor: unify agent and session identity

This commit is contained in:
Tianyi Cui
2026-07-14 01:59:21 +08:00
parent 7a33ee94be
commit 709cc7200e
105 changed files with 899 additions and 948 deletions

View File

@@ -12,7 +12,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
- `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.get(id: SessionId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
#### Factory seam (creation)

View File

@@ -9,7 +9,7 @@ import { Context, getTraceable, Service, symbols } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentId, AgentOptions } from './types.ts'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export { agentEvents, assembleContextFor } from './dispatch.ts'
@@ -33,15 +33,13 @@ declare module 'cordis' {
/**
* Options for programmatically creating an agent through the registry factory
* ({@link AgentRegistry.create}). The caller supplies the live `sessionId`
* (e.g. an ACP-generated id) and optional session metadata (the validated
* `cwd`, fork lineage); the factory creates the session, the agent, and wires
* them together.
* ({@link AgentRegistry.create}). The caller supplies the single live
* `sessionId` shared by the agent registry and session log (e.g. an
* ACP-generated id), plus optional session metadata (the validated `cwd`, fork
* lineage); the factory creates the session and agent under that identity.
*/
export interface CreateAgentOptions {
/** The agent's id (the registry handle). */
readonly agentId: AgentId
/** The live session's id (NOT derived from agentId). */
/** The live agent/session identity. */
readonly sessionId: SessionId
/**
* Session creation metadata: validated absolute `cwd`, `parentSession`
@@ -93,9 +91,7 @@ export interface CreateAgentOptions {
* ({@link AgentRegistry.resume}).
*/
export interface ResumeAgentOptions {
/** The agent's id (the registry handle). */
readonly agentId: AgentId
/** The persisted session id to load and resume on. */
/** The persisted session id to load and use as the live agent/session identity. */
readonly resumeSessionId: SessionId
/** Per-agent options (model, …). */
readonly agentOptions?: AgentOptions
@@ -180,7 +176,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
/** All mutable lifecycle state for one exact registry entry. */
interface AgentEntry {
readonly id: AgentId
readonly id: SessionId
readonly agent: Agent
readonly carrier: Scoped<Agent>
announced: boolean
@@ -201,7 +197,7 @@ interface FactorySlot {
* {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<AgentId, AgentEntry>()
private store = new Map<SessionId, AgentEntry>()
private factory: FactorySlot | undefined
constructor(ctx: Context) {
@@ -257,7 +253,7 @@ export class AgentRegistry extends Service {
* agent): this constructs the agent and its session. Rejects if no factory is
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
* the owner tear down exactly this agent.
* @param options - agent id, session id/seed/metadata, and agent options.
* @param options - shared identity, session seed/metadata, and agent options.
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async create(options: CreateAgentOptions): Promise<AgentHandle> {
@@ -428,10 +424,10 @@ export class AgentRegistry extends Service {
/**
* Look up a live agent.
* @param id - the agent id to look up.
* @param id - the shared agent/session id to look up.
* @returns the agent, or undefined when no live agent has that id.
*/
get(id: AgentId): Agent | undefined {
get(id: SessionId): Agent | undefined {
return this.store.get(id)?.agent
}

View File

@@ -32,7 +32,7 @@
* event. A turn/step boundary is a durable fact: it lives in the session log
* and is read off the `session/event` feed — it is NOT mirrored as an `agent/*`
* emit. A consumer that needs the `Agent` handle (or its short id) at a boundary
* keeps a session-id→agent map from `agent/created`/`agent/disposed`.
* looks up the agent directly by the event's session id.
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
*
@@ -45,25 +45,12 @@
* @module @deepseek-ai/dsh-agent/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Context } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
/** Identifies one live agent in the registry. */
export type AgentId = Branded<'AgentId'>
/**
* Brand a string as an {@link AgentId}.
* @param id - the raw agent id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function AgentId(id: string): AgentId {
return id as AgentId
}
import type { Session } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
/**
@@ -186,7 +173,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
* package should depend on the implementation.
*/
export interface Agent {
readonly id: AgentId
/** The single identity shared with {@link session}. */
readonly id: SessionId
readonly options: AgentOptions
readonly session: Session
readonly status: AgentStatus

View File

@@ -2,11 +2,12 @@ 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 AgentRegistry, { agentEvents } 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)
const id = SessionId(rawId)
return {
id,
options: {},
@@ -57,7 +58,7 @@ describe('AgentRegistry', () => {
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined()
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
})
@@ -158,11 +159,11 @@ describe('AgentRegistry factory seam', () => {
const factory: AgentFactory = {
async createAgent(ownerCtx, options) {
calls.create.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
},
async resume(ownerCtx, options) {
calls.resume.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
},
}
return { factory, calls }
@@ -171,15 +172,15 @@ describe('AgentRegistry factory seam', () => {
it('requires a factory and delegates through the calling context', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
let callerFiber: Context['fiber'] | undefined
await ctx.plugin(Object.assign(async (inner: Context) => {
callerFiber = inner.fiber
await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
await inner.agents.create({ sessionId: SessionId('create-s') })
await inner.agents.resume({ resumeSessionId: SessionId('resume-s') })
}, { inject: ['agents'] }))
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
@@ -192,9 +193,9 @@ describe('AgentRegistry factory seam', () => {
inner.agents.setFactory(stubFactory().factory)
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
}, { inject: ['agents'] }))
await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined()
await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined()
await owner.dispose()
await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
})
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
@@ -214,18 +215,18 @@ describe('AgentRegistry factory seam', () => {
}
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
this.calls().push('create')
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() }
}
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
this.calls().push('resume')
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() }
}
}
await ctx.plugin(TracedFactory)
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
ctx.agents.setFactory(traced)
await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
await ctx.agents.create({ sessionId: SessionId('create-s') })
await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') })
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
expect(states.get(raw!)).toEqual(['create', 'resume'])
})