Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary (merge-extensible maps), raw StreamChunk protocol, ToolSchema, abstract LlmAdapter, LlmService adapter registry, BlockAssembler. @deepseek-ai/dsh-session: event-sourced Session (append-only log, deriveMessages; context/steering render as tagged envelopes), SessionStore, session/event + awaited session/flush durability seam. @deepseek-ai/dsh-system-prompt: ordered sections + tool-schema providers; assemble() through the system-prompt/assemble waterfall. Tool schemas are part of the assembly by design. @deepseek-ai/dsh-tools: tool registry feeding schemas into the assembly; execute() through the tools/execute waterfall (the single sandbox/permission/hook seam). @deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort, spawn/fork TODO seams), AgentRegistry, and the full agent/* event taxonomy so plugins never depend on the concrete loop.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionEventMap, SessionEventType } from './types.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessions: SessionStore
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/** A session was created in the store. */
|
||||
'session/created'(session: Session): void
|
||||
/** An event was appended to a session log (sync, fire-and-forget). */
|
||||
'session/event'(session: Session, event: SessionEvent): void
|
||||
/**
|
||||
* Awaited durability checkpoint. The agent loop awaits
|
||||
* `ctx.parallel('session/flush', session)` at every turn end; persistence
|
||||
* plugins (JSONL, sqlite — TODO, future phase) drain their write-behind
|
||||
* buffers here and on fiber dispose.
|
||||
*/
|
||||
'session/flush'(session: Session): Promise<void> | void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a `context/message` or `steering/message` event as a tagged
|
||||
* synthetic user-role message (the system-reminder pattern: zero adapter
|
||||
* burden, models distinguish it from real user prompts by the envelope).
|
||||
*
|
||||
* TODO(review): revisit the envelope once a real adapter exists.
|
||||
*/
|
||||
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
|
||||
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
|
||||
const close = `</${tag}>`
|
||||
return [
|
||||
{ type: 'text', text: open },
|
||||
...content,
|
||||
{ type: 'text', text: close },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
|
||||
* Seeding with an existing event log replays/forks a session.
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
/** Set by the store so appends are observable; no-op when detached. */
|
||||
onAppend?: (event: SessionEvent) => void
|
||||
|
||||
constructor(public readonly id: string, seed?: SessionEvent[]) {
|
||||
if (seed) this.log = [...seed]
|
||||
}
|
||||
|
||||
get events(): readonly SessionEvent[] {
|
||||
return this.log
|
||||
}
|
||||
|
||||
get seq(): number {
|
||||
return this.log.length
|
||||
}
|
||||
|
||||
/** Append one event. Synchronous — the hot path never blocks on I/O. */
|
||||
append<T extends SessionEventType>(type: T, data: SessionEventMap[T]): SessionEvent<T> {
|
||||
const event: SessionEvent<T> = { type, seq: this.log.length, time: Date.now(), data }
|
||||
this.log.push(event as SessionEvent)
|
||||
this.onAppend?.(event as SessionEvent)
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the LLM message history from the event log.
|
||||
*
|
||||
* - `user/message` → user message
|
||||
* - `assistant/message` → assistant message (chunks are skipped — they are
|
||||
* replay/UI data; the assembled message is authoritative for history)
|
||||
* - `tool/result` → user message carrying a tool-result block
|
||||
* - `context/message` / `steering/message` → tagged synthetic user messages
|
||||
* at their chronological position
|
||||
*/
|
||||
deriveMessages(): Message[] {
|
||||
const messages: Message[] = []
|
||||
for (const event of this.log) {
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const { content } = event.data as SessionEventMap['user/message']
|
||||
messages.push({ role: 'user', content })
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const { content } = event.data as SessionEventMap['assistant/message']
|
||||
messages.push({ role: 'assistant', content })
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const { callId, content, isError } = event.data as SessionEventMap['tool/result']
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data as SessionEventMap['context/message']
|
||||
messages.push({ role: 'user', content: renderTagged('context', content, source) })
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data as SessionEventMap['steering/message']
|
||||
messages.push({ role: 'user', content: renderTagged('steering', content, source) })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session store (`ctx.sessions`).
|
||||
*
|
||||
* Persistence is intentionally not implemented here — persistence plugins
|
||||
* subscribe to `session/event` and flush on `session/flush` / dispose.
|
||||
*/
|
||||
export class SessionStore extends Service {
|
||||
private store = new Map<string, Session>()
|
||||
private counter = 0
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessions')
|
||||
}
|
||||
|
||||
/** Create a session. `seed` replays/forks an existing event log. */
|
||||
create(id?: string, seed?: SessionEvent[]): Session {
|
||||
id ??= `session-${++this.counter}`
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
const session = new Session(id, seed)
|
||||
session.onAppend = (event) => this.ctx.emit('session/event', session, event)
|
||||
this.ctx.effect(() => {
|
||||
this.store.set(id, session)
|
||||
this.ctx.emit('session/created', session)
|
||||
return () => {
|
||||
session.onAppend = undefined
|
||||
this.store.delete(id)
|
||||
}
|
||||
}, 'sessions.create()')
|
||||
return session
|
||||
}
|
||||
|
||||
get(id: string): Session | undefined {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionStore
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* What started a turn.
|
||||
* Merge-extensible sum type (same pattern as MessageSourceMap).
|
||||
*/
|
||||
export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
continuation: { kind: 'continuation' }
|
||||
}
|
||||
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
|
||||
/**
|
||||
* Why a turn ended.
|
||||
* Merge-extensible sum type.
|
||||
*/
|
||||
export interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
aborted: { kind: 'aborted'; reason?: string }
|
||||
error: { kind: 'error'; message: string; code?: string }
|
||||
disposed: { kind: 'disposed' }
|
||||
}
|
||||
|
||||
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
|
||||
/**
|
||||
* The session event vocabulary — the append-only source of truth for an
|
||||
* agent's whole interaction history. The LLM message history is *derived*
|
||||
* from this log; nothing else is authoritative. Replay = re-derive from the
|
||||
* same events; trace/telemetry = subscribe to the log.
|
||||
*
|
||||
* Merge-extensible: plugins declare extra event types via declaration merging
|
||||
* (e.g. a compaction plugin adds `'compaction/marker'`).
|
||||
*
|
||||
* TODO(review): this vocabulary needs careful review once the loop and the
|
||||
* first persistence plugin exist side by side.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
'step/start': { turn: number; step: number }
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as tagged synthetic context — NOT a user prompt.
|
||||
*/
|
||||
'context/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
||||
/** Assembled assistant message for one step (derived history uses this). */
|
||||
'assistant/message': { turn: number; step: number; content: ContentBlock[] }
|
||||
'tool/call': { turn: number; step: number; callId: string; name: string; arguments: string }
|
||||
'tool/result': { turn: number; step: number; callId: string; content: ContentBlock[]; isError: boolean }
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
|
||||
'usage': { turn: number; step: number; usage: TokenUsage }
|
||||
'error': { turn: number; step: number; message: string; code?: string }
|
||||
}
|
||||
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
/** One immutable entry in the session log. */
|
||||
export interface SessionEvent<T extends SessionEventType = SessionEventType> {
|
||||
type: T
|
||||
/** Monotonic sequence number within the session. */
|
||||
seq: number
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[T]
|
||||
}
|
||||
Reference in New Issue
Block a user