Reorganize packages into a modular hierarchy

Move the 18 flat packages/<name> packages into role-grouped dirs:
core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are
pure containers; each package keeps its @deepseek-ai/dsh-* name.

Collapse the per-package tsconfig paths maps (base + typecheck) into one
@deepseek-ai/dsh-* wildcard with a candidate per group, and derive the
publint list from the hierarchy. Update all depth-coupled globs/configs
(workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs,
per-package tsconfigs, generators, doc-script scopes, type-equiv manifest)
and the cross-package/script relative imports in tests.

Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the
TypeScript API instead of a regex comment-strip, which corrupted the
new wildcard `/*/` path candidates.

WIP: doc cross-links and package/RFC docs still to update.
This commit is contained in:
Tianyi Cui
2026-06-20 22:55:20 +08:00
parent 906705e353
commit d02e9f1bd6
191 changed files with 822 additions and 624 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* Agent registry service. Tracks live agents so plugins can find them without
* depending on the concrete loop package. Agent creation belongs to the loop.
*
* @module @deepseek-ai/dsh-agent
*/
import { Context, Service } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
declare module 'cordis' {
interface Context {
agents: AgentRegistry
}
}
/**
* 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.
*/
export interface CreateAgentOptions {
/** The agent's id (the registry handle). */
agentId: string
/** The live session's id (NOT derived from agentId). */
sessionId: string
/**
* Session creation metadata: validated absolute `cwd` and `parentSession`
* fork lineage. Mirrors the `cwd`/`parentSession` fields of
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
* `createdAt`, used when reconstructing a persisted session, is deliberately
* excluded — a factory caller never sets it).
*/
meta?: { cwd?: string; parentSession?: SessionId }
/** Per-agent options (model, system prompt). */
agentOptions?: AgentOptions
}
/**
* Options for resuming an agent on a persisted session
* ({@link AgentRegistry.resume}).
*/
export interface ResumeAgentOptions {
/** The agent's id (the registry handle). */
agentId: string
/** The persisted session id to load and resume on. */
resumeSessionId: string
/** Per-agent options (model, system prompt). */
agentOptions?: AgentOptions
}
/**
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
* removes the agent's session from the store, in an order that captures the
* loop's final `session/flush` before the session is detached.
*
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
* for the OWNER that created it. Config-created agents (the loop's own startup)
* are owned by the loop fiber and never need a handle.
*/
export interface AgentHandle {
agent: Agent
dispose(): Promise<void>
}
/**
* The agent-creation factory the loop implementation provides to the registry
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
* consumers (e.g. the ACP bridge) program against `ctx.agents` without
* depending on the concrete `dsh-agent-loop` package.
*/
export interface AgentFactory {
/**
* Create, start, and register a new agent on a caller-supplied session id.
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
* this agent (unregister + stop loop + await quiescence + remove session).
*/
createAgent(options: CreateAgentOptions): AgentHandle
/**
* Load a persisted session and resume an agent on it. Async because it awaits
* `ctx.sessionPersistence.load`; must be called after that service exists
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
*/
resume(options: ResumeAgentOptions): Promise<AgentHandle>
}
/** Thrown when create/resume is called before an agent factory is registered. */
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
/**
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
* orchestrator plugins can find them without depending on the concrete loop
* package. Agent *creation* is provided by whichever plugin implements the
* {@link AgentFactory} (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via
* {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<string, Agent>()
private factory: AgentFactory | undefined
constructor(ctx: Context) {
super(ctx, 'agents')
}
/**
* Register the agent-creation factory (the loop calls this on construction,
* effect-scoped). Throws if a factory is already registered. Returns the
* disposer; on dispose the factory slot is cleared.
*/
setFactory(factory: AgentFactory): () => void {
const dispose = this.ctx.effect(() => {
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
this.factory = factory
return () => { this.factory = undefined }
}, 'agents.setFactory()')
return () => void dispose()
}
/**
* Create, start, and register a new agent through the registered factory.
* Distinct from {@link register} (which records an already-constructed
* agent): this constructs the agent and its session. Throws if no factory is
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
* down exactly this agent.
*/
create(options: CreateAgentOptions): AgentHandle {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.createAgent(options)
}
/**
* Load a persisted session and resume an agent on it through the registered
* factory. Rejects if no factory is registered; the factory rejects if
* session persistence is not configured. Returns an {@link AgentHandle}.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.resume(options)
}
/**
* Register a live agent. Throws if an agent with the same id is already
* registered. Emits `agent/created` on registration and `agent/disposed`
* when the calling fiber is disposed. Returns the disposer.
*/
register(agent: Agent): () => void {
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
if (this.store.has(agent.id)) {
throw new Error(`agent "${agent.id}" is already registered`)
}
this.store.set(agent.id, agent)
// Yield the rollback BEFORE emitting `agent/created`: a generator effect
// collects each yielded disposer before the next step runs, so a
// throwing `agent/created` listener rolls the entry back instead of
// leaking it (a leak would wedge the duplicate-id check until restart).
// The duplicate throw above fires before any mutation — it leaks nothing.
yield () => {
this.store.delete(agent.id)
// CONTAIN a throwing `agent/disposed` listener: this disposer runs as
// one link in the owning fiber/effect's disposal chain, and Cordis
// chains later disposers with `task.then(next)` — so an UNCAUGHT throw
// here rejects the chain and SKIPS every later disposer. When this
// registration shares a composite effect with a session (the agent
// factory's `AgentLoop.start`, where the session-detach disposer runs
// AFTER this one), a swallowed-less throw would strand the session in
// the store with `onAppend` attached — a leak AND a durability hole.
// The store entry is already removed above (the useful state), so
// logging the listener bug and continuing is correct (mirrors the
// guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent).
try {
this.ctx.emit('agent/disposed', agent)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
}
}
this.ctx.emit('agent/created', agent)
}.bind(this), 'agents.register()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
get(id: string): Agent | undefined {
return this.store.get(id)
}
list(): Agent[] {
return [...this.store.values()]
}
}
export default AgentRegistry
+225
View File
@@ -0,0 +1,225 @@
/**
* Agent interface and event taxonomy. Every plugin programs against the
* `Agent` handle defined here; the concrete implementation lives in
* `@deepseek-ai/dsh-agent-loop`.
*
* Merge-extensible: `AgentOptions` supports declaration merging for
* plugin-specific creation options.
*
* @module @deepseek-ai/dsh-agent/types
*/
import type { Branded, ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
/** Identifies one live agent in the registry. */
export type AgentId = Branded<'AgentId'>
/** Brand a string as an {@link AgentId}. */
export function AgentId(id: string): AgentId {
return id as AgentId
}
import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session'
/**
* Options an agent is created with.
* Merge-extensible: plugins declare extra fields via declaration merging.
*/
export interface AgentOptions {
/** Model name (must have a registered adapter at call time). */
model?: string
/** Per-agent system prompt appended after the assembled sections. */
systemPrompt?: string
}
export interface SendOptions {
source?: MessageSource
}
export type AgentStatus = 'idle' | 'running' | 'disposed'
/**
* The agent handle — the surface every plugin (UI, hooks, orchestrators)
* programs against. The concrete implementation lives in
* `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop
* package should depend on the implementation.
*/
export interface Agent {
readonly id: AgentId
readonly options: AgentOptions
readonly session: Session
readonly status: AgentStatus
/** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
send(content: ContentBlock[], options?: SendOptions): void
/**
* Steer a running turn: content is injected between steps of the current
* turn. When idle, behaves like {@link send}.
*/
steer(content: ContentBlock[], options?: SendOptions): void
/**
* Inject in-session context (file-change notices, skill content, cron
* notifications, …): appends a `context/message` session event the next model
* request sees at its chronological position, rendered as tagged synthetic
* context rather than a user prompt. Does not run the model.
*
* Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn;
* an inject while idle wraps its `context/message` in a one-shot `injection`
* turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for
* durability, so every event stays inside a turn and a persistence backend
* never loses a between-turn notice. The idle checkpoint is fire-and-forget
* (inject is synchronous): a failing flush is reported via `agent/error`
* (step `0`) and the logger, never thrown into the caller.
*
* Live-adapter review has validated the tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
*/
inject(content: ContentBlock[], options?: SendOptions): void
/** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */
abort(reason?: string): void
/**
* Cancel ALL pending work for the agent — the narrower {@link abort} kills
* only the in-flight step. `cancel()`:
*
* - clears the queued FIFO (un-started prompts never run) and the steering
* FIFO (steering for the cancelled turn is dropped, not re-enqueued);
* - aborts the in-flight step if one is running (the turn ends `aborted`);
* - drops a turn that is about to start (a `cancel()` landing in the
* pre-step window — after a `send()` queued but before the loop flips to
* `running`, or after `running` is emitted but before the first step) so
* that queued prompt does not run and cannot be batched into the cancelled
* turn.
*
* After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state.
* `cancel()` on an idle agent with nothing queued or running is a safe no-op
* — it does NOT arm anything that would drop a later legitimate prompt.
*/
cancel(reason?: string): void
/**
* Resolve once the agent has reached quiescence after settling out of
* `running`, or immediately if it is already idle with no queued work. The
* quiescence signal a teardown awaits: `agent.abort()` then
* `await agent.whenIdle()` guarantees queued/running work has fully stopped
* before the caller proceeds (a closing ACP connection, a disposing UI
* plugin), rather than returning while the driver is still streaming or about
* to start a queued turn.
*
* "Quiescence", not merely "status changed": a disposed agent emits
* `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
* has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop
* to actually exit (the implementation chains the loop-exit promise), not just
* observe the status flip. A mid-step disposal that never reaches `idle` still
* unblocks the await this way.
*
* Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing
* the agent down. A consumer that owns the agent's lifecycle disposes it
* separately.
*/
whenIdle(): Promise<void>
// TODO(sub-agents): spawn/fork seams — semantics deliberately deferred.
// The intended shape: a creation option referencing a parent agent
// (fork = seed the child Session with the parent's event log; spawn =
// fresh Session), with the child returned as an Agent handle so steer()
// and event subscription work uniformly. See docs/architecture.md.
}
declare module 'cordis' {
interface Events {
// ---- lifecycle (emit) ----
/**
* An agent was registered in the {@link AgentRegistry} and is ready to
* receive messages.
* @mode emit
*/
'agent/created'(agent: Agent): void
/**
* An agent was disposed and removed from the registry; its fiber and any
* in-flight turn have been torn down.
* @mode emit
*/
'agent/disposed'(agent: Agent): void
/**
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
* lifecycle off this transition, never off a status you just requested —
* `send()` does not flip status to `running` before it returns.
* @mode emit
*/
'agent/status'(agent: Agent, status: AgentStatus): void
/**
* A message entered the agent's inbox (queued or steering). `source` is
* the resolved source (defaults applied), not the caller's raw options.
* @mode emit
*/
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
// ---- turn/step boundaries (emit) ----
/**
* A turn began. `turn` is the 1-based turn number within the session.
* @mode emit
*/
'agent/turn-start'(agent: Agent, turn: number): void
/**
* A turn ended. `reason` distinguishes a clean stop from a truncated or
* aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`).
* @mode emit
*/
'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void
/**
* A step (one model call plus its tool dispatch) began. `step` is 1-based
* within the turn; a turn runs one or more steps.
* @mode emit
*/
'agent/step-start'(agent: Agent, turn: number, step: number): void
/**
* A step ended.
* @mode emit
*/
'agent/step-end'(agent: Agent, turn: number, step: number): void
// ---- interception seams (waterfall) ----
/**
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
* model call (hooks, compaction, model switching, tool filtering, …). Call
* `next()` to delegate, or return without it to short-circuit.
* @mode waterfall
*/
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
/**
* Waterfall: post-process the assembled assistant {@link Message} before
* tool dispatch (validation, content rewriting, …).
* @mode waterfall
*/
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
/**
* Waterfall: override the turn-continuation decision. The default
* (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners
* can force-continue (/goal, /loop) or force-stop (budget guards).
* @mode waterfall
*/
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise<boolean>): Promise<boolean>
// ---- streaming + tool notifications (emit) ----
/**
* A raw {@link StreamChunk} arrived from the model (token-level UI/log feed).
* @mode emit
*/
'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void
/**
* Steering content was injected into a running turn.
* @mode emit
*/
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
/**
* 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.
* @mode emit
*/
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
}
}