diff --git a/examples/echo-agent/src/mock-llm.ts b/examples/echo-agent/src/mock-llm.ts index 441e5639e3..93132711de 100644 --- a/examples/echo-agent/src/mock-llm.ts +++ b/examples/echo-agent/src/mock-llm.ts @@ -1,6 +1,6 @@ import type { Context } from 'cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' /** * Demo adapter for the `mock-echo` model. @@ -30,8 +30,8 @@ class MockEchoAdapter extends LlmAdapter { } yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Let me echo that for you.' } } yield { type: 'block-start', index: 1, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 1, id: 'call-echo', name: 'echo', argumentsDelta: args } - yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'call-echo', name: 'echo', arguments: args } } + yield { type: 'tool-call-delta', index: 1, id: CallId('call-echo'), name: 'echo', argumentsDelta: args } + yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-echo'), name: 'echo', arguments: args } } yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } yield { type: 'finish', reason: { kind: 'tool-calls' } } return diff --git a/packages/agent-loop/src/agent.ts b/packages/agent-loop/src/agent.ts index 3c9a2ad914..bc8c72650f 100644 --- a/packages/agent-loop/src/agent.ts +++ b/packages/agent-loop/src/agent.ts @@ -7,7 +7,7 @@ */ import type { Context } from 'cordis' -import type { AgentOptions, AgentStatus, SendOptions } 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 type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' @@ -33,7 +33,7 @@ export class LoopAgent implements Agent { constructor( private ctx: Context, - public readonly id: string, + public readonly id: AgentId, public readonly options: AgentOptions, public readonly session: Session, ) { diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts index 6484ec9ace..460bf52a88 100644 --- a/packages/agent-loop/src/index.ts +++ b/packages/agent-loop/src/index.ts @@ -9,6 +9,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' +import { AgentId } from '@deepseek-ai/dsh-agent' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-session' @@ -67,7 +68,7 @@ export class AgentLoop extends Service { */ create(id: string, options: AgentOptions = {}): LoopAgent { const session = this.ctx.sessions.create(`${id}-session`) - const agent = new LoopAgent(this.ctx, id, options, session) + const agent = new LoopAgent(this.ctx, AgentId(id), options, session) // Generator effect: stop and unregister are independent disposables // (LIFO), so a throwing stop() cannot leak the registry entry. this.ctx.effect(function* (this: AgentLoop) { diff --git a/packages/agent-loop/tests/agent.spec.ts b/packages/agent-loop/tests/agent.spec.ts index 8cd591a6f2..26933188fc 100644 --- a/packages/agent-loop/tests/agent.spec.ts +++ b/packages/agent-loop/tests/agent.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -101,7 +102,7 @@ describe('LoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create('test') - const agent = new LoopAgent(ctx, 'bare', { model: 'mock' }, session) + const agent = new LoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) // Start the loop to get the disposer; the agent waits for messages // (idle, never-resolving cancel), so it will stay idle. diff --git a/packages/agent-loop/tests/coverage-edges.spec.ts b/packages/agent-loop/tests/coverage-edges.spec.ts index 77d39d15d8..4d1f03c01e 100644 --- a/packages/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/agent-loop/tests/coverage-edges.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' @@ -100,7 +100,7 @@ describe('tool JSON parse', () => { // model emits tool-call with malformed arguments (not valid JSON) [ { type: 'block-start' as const, index: 0, blockType: 'tool-call' as const }, - { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: 'c1', name: 'echo', arguments: 'not json' } }, + { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: 'not json' } }, { type: 'finish' as const, reason: { kind: 'tool-calls' as const } }, ] satisfies StreamChunk[], textResponse('done'), @@ -133,7 +133,7 @@ describe('tool JSON parse', () => { const adapter = new MockAdapter([ [ { type: 'block-start' as const, index: 0, blockType: 'tool-call' as const }, - { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: 'c1', name: 'noarg', arguments: '' } }, + { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'noarg', arguments: '' } }, { type: 'finish' as const, reason: { kind: 'tool-calls' as const } }, ] satisfies StreamChunk[], textResponse('done'), diff --git a/packages/agent-loop/tests/mock-adapter.ts b/packages/agent-loop/tests/mock-adapter.ts index 416f3b444d..f6cb47cda3 100644 --- a/packages/agent-loop/tests/mock-adapter.ts +++ b/packages/agent-loop/tests/mock-adapter.ts @@ -1,5 +1,5 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' /** Helpers to write scripted responses tersely. */ export function textResponse(text: string): StreamChunk[] { @@ -12,7 +12,8 @@ export function textResponse(text: string): StreamChunk[] { ] } -export function toolCallResponse(callId: string, name: string, args: object, text?: string): StreamChunk[] { +export function toolCallResponse(rawCallId: string, name: string, args: object, text?: string): StreamChunk[] { + const callId = CallId(rawCallId) const argumentsJson = JSON.stringify(args) const chunks: StreamChunk[] = [] let index = 0 diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/agent-loop/tests/review-fixes.spec.ts index f5cea25ef2..4ac6152308 100644 --- a/packages/agent-loop/tests/review-fixes.spec.ts +++ b/packages/agent-loop/tests/review-fixes.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -65,7 +65,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( role: 'assistant' as const, content: [ { type: 'text' as const, text: 'rewritten' }, - { type: 'tool-call' as const, id: 'c-injected', name: 'injected-tool', arguments: '{}' }, + { type: 'tool-call' as const, id: CallId('c-injected'), name: 'injected-tool', arguments: '{}' }, ], } }) @@ -96,9 +96,9 @@ describe('HIGH: abort during tool execution ends the turn', () => { // model asks for two tool calls in one step [ { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'c1', name: 'aborter', arguments: '{}' } }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } }, { type: 'block-start', index: 1, blockType: 'tool-call' }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'c2', name: 'second', arguments: '{}' } }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] satisfies StreamChunk[], textResponse('should never be requested'), @@ -429,7 +429,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () ctx2.llm.registerAdapter(['mock'], second) const seeded = ctx2.sessions.create('forked', [...agent.session.events]) - const forked = new LoopAgent(ctx2, 'forked-agent', { model: 'mock' }, seeded) + const forked = new LoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) ctx2.effect(() => forked.start()) const turns: number[] = [] @@ -459,10 +459,10 @@ describe('LOW: BlockAssembler and streamBlocks edge cases', () => { it('assembles tool-call blocks from deltas without block-end', async () => { const { BlockAssembler } = await import('@deepseek-ai/dsh-llm') const assembler = new BlockAssembler() - assembler.push({ type: 'tool-call-delta', index: 0, id: 'c9', name: 'echo', argumentsDelta: '{"a"' }) - assembler.push({ type: 'tool-call-delta', index: 0, id: 'c9', argumentsDelta: ':1}' }) + assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c9'), name: 'echo', argumentsDelta: '{"a"' }) + assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c9'), argumentsDelta: ':1}' }) expect(assembler.blocks()).toEqual([ - { type: 'tool-call', id: 'c9', name: 'echo', arguments: '{"a":1}' }, + { type: 'tool-call', id: CallId('c9'), name: 'echo', arguments: '{"a":1}' }, ]) }) @@ -531,9 +531,9 @@ describe('LOW: BlockAssembler and streamBlocks edge cases', () => { describe('LOW: discriminated SessionEvent narrows without casts', () => { it('narrows event.data from event.type', () => { - const session = new Session('s') + const session = new Session(SessionId('s')) const appended: SessionEvent = session.append('tool/call', { - turn: 1, step: 1, callId: 'c1', name: 'echo', arguments: '{}', + turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}', }) // compile-time: this switch narrows; runtime: values flow through switch (appended.type) { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index f54f8d5d8e..0441938b6d 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -9,7 +9,15 @@ * @module @deepseek-ai/dsh-agent/types */ -import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +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' /** @@ -36,7 +44,7 @@ export type AgentStatus = 'idle' | 'running' | 'disposed' * package should depend on the implementation. */ export interface Agent { - readonly id: string + readonly id: AgentId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus diff --git a/packages/agent/tests/agent.spec.ts b/packages/agent/tests/agent.spec.ts index a90918044a..5d66035421 100644 --- a/packages/agent/tests/agent.spec.ts +++ b/packages/agent/tests/agent.spec.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { Session } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Agent } from '@deepseek-ai/dsh-agent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent' -function stubAgent(id: string): Agent { +function stubAgent(rawId: string): Agent { + const id = AgentId(rawId) return { id, options: {}, - session: new Session(`${id}-session`), + session: new Session(SessionId(`${id}-session`)), status: 'idle', send() {}, steer() {}, diff --git a/packages/llm/src/assembler.ts b/packages/llm/src/assembler.ts index 65cd98dcda..a75b9a7418 100644 --- a/packages/llm/src/assembler.ts +++ b/packages/llm/src/assembler.ts @@ -5,12 +5,13 @@ * @module @deepseek-ai/dsh-llm/assembler */ +import { CallId } from './brand.ts' import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts' interface PartialBlock { blockType: string text: string - toolCallId?: string + toolCallId?: CallId toolCallName?: string toolCallArguments: string /** Set by `block-end` — authoritative, and freezes the partial. */ @@ -101,7 +102,7 @@ export class BlockAssembler { case 'reasoning': return { type: 'reasoning', text: partial.text } case 'tool-call': return { type: 'tool-call', - id: partial.toolCallId ?? `call-${index}`, + id: partial.toolCallId ?? CallId(`call-${index}`), name: partial.toolCallName ?? '', arguments: partial.toolCallArguments, } diff --git a/packages/llm/src/brand.ts b/packages/llm/src/brand.ts new file mode 100644 index 0000000000..38d69fe37b --- /dev/null +++ b/packages/llm/src/brand.ts @@ -0,0 +1,32 @@ +/** + * Branded (nominal) ID types. + * + * A brand makes structurally-identical strings non-interchangeable at the + * type level: an `AgentId` cannot be passed where a `CallId` is expected, + * even though both are strings at runtime. Construction goes through the + * per-type factory (a plain cast inside — zero runtime cost); comparison, + * logging, and serialization all behave as ordinary strings. + * + * Policy: core packages brand the IDs they own — `CallId` here (tool-call + * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent. Branding + * is for IDs that cross package boundaries and could plausibly be confused; + * not every string needs a brand. + * + * @module @deepseek-ai/dsh-llm/brand + */ + +declare const BRAND: unique symbol + +/** A string carrying a compile-time-only brand `B`. */ +export type Branded = string & { readonly [BRAND]: B } + +/** + * Correlates a model-issued tool call with its result. Provider-issued for + * real adapters; synthesized by mocks/assembler fallbacks. + */ +export type CallId = Branded<'CallId'> + +/** Brand a string as a {@link CallId}. */ +export function CallId(id: string): CallId { + return id as CallId +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 9a1bad3f29..4158853d84 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -10,6 +10,7 @@ import { Context, Service } from 'cordis' import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types.ts' import { BlockAssembler } from './assembler.ts' +export * from './brand.ts' export * from './types.ts' export { BlockAssembler } from './assembler.ts' diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index 2b16095e74..484a172b38 100644 --- a/packages/llm/src/types.ts +++ b/packages/llm/src/types.ts @@ -19,6 +19,8 @@ * ``` */ +import type { CallId } from './brand.ts' + /** Cache hint attached to a content block (provider-interpreted). */ export type CacheHint = 'ephemeral' @@ -39,7 +41,7 @@ export interface ReasoningBlock { export interface ToolCallBlock { type: 'tool-call' /** Provider-issued call id; correlates with the matching tool result. */ - id: string + id: CallId name: string /** Raw JSON string as produced by the model. */ arguments: string @@ -48,7 +50,7 @@ export interface ToolCallBlock { /** The result of a tool invocation, sent back to the model. */ export interface ToolResultBlock { type: 'tool-result' - toolCallId: string + toolCallId: CallId content: ContentBlock[] isError?: boolean cache?: CacheHint @@ -134,7 +136,7 @@ export type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'text-delta'; index: number; text: string } | { type: 'reasoning-delta'; index: number; text: string } - | { type: 'tool-call-delta'; index: number; id: string; name?: string; argumentsDelta: string } + | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string } | { type: 'block-end'; index: number; block: ContentBlock } | { type: 'usage'; usage: TokenUsage } | { type: 'finish'; reason: FinishReason } diff --git a/packages/llm/tests/assembler.spec.ts b/packages/llm/tests/assembler.spec.ts index 9fdf3d541f..db8cb32820 100644 --- a/packages/llm/tests/assembler.spec.ts +++ b/packages/llm/tests/assembler.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { BlockAssembler, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' describe('BlockAssembler', () => { it('assembles interleaved text, reasoning, and tool-call deltas', () => { @@ -11,8 +11,8 @@ describe('BlockAssembler', () => { { type: 'text-delta', index: 1, text: 'Hello' }, { type: 'text-delta', index: 1, text: ' world' }, { type: 'block-start', index: 2, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'echo', argumentsDelta: '{"text":' }, - { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '"hi"}' }, + { type: 'tool-call-delta', index: 2, id: CallId('call-1'), name: 'echo', argumentsDelta: '{"text":' }, + { type: 'tool-call-delta', index: 2, id: CallId('call-1'), argumentsDelta: '"hi"}' }, { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] @@ -22,7 +22,7 @@ describe('BlockAssembler', () => { expect(assembler.blocks()).toEqual([ { type: 'reasoning', text: 'thinking…' }, { type: 'text', text: 'Hello world' }, - { type: 'tool-call', id: 'call-1', name: 'echo', arguments: '{"text":"hi"}' }, + { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{"text":"hi"}' }, ]) expect(assembler.usage).toEqual({ inputTokens: 10, outputTokens: 5 }) expect(assembler.finish).toEqual({ kind: 'tool-calls' }) @@ -125,11 +125,11 @@ describe('BlockAssembler', () => { it('ignores tool-call-delta stragglers after block-end', () => { const assembler = new BlockAssembler() assembler.push({ type: 'block-start', index: 0, blockType: 'tool-call' }) - assembler.push({ type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{}' }) - assembler.push({ type: 'block-end', index: 0, block: { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } }) + assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'echo', argumentsDelta: '{}' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' } }) // straggler after block-end — partial.block is set, so early return - assembler.push({ type: 'tool-call-delta', index: 0, id: 'c1', name: 'evil', argumentsDelta: 'oops' }) - expect(assembler.blocks()).toEqual([{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' }]) + assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'evil', argumentsDelta: 'oops' }) + expect(assembler.blocks()).toEqual([{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }]) }) it('assembles tool-call with generated id fallback when no id provided', () => { @@ -138,7 +138,7 @@ describe('BlockAssembler', () => { // No id and no name provided — uses fallback id `call-{index}` and empty name const blocks = assembler.blocks() expect(blocks).toEqual([ - { type: 'tool-call', id: 'call-0', name: '', arguments: '{}' }, + { type: 'tool-call', id: CallId('call-0'), name: '', arguments: '{}' }, ]) }) diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index b19fc47336..7b3b04812c 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -8,6 +8,7 @@ import { Context, Service } from 'cordis' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionId } from './types.ts' import type { SessionEvent, SessionEventMap, SessionEventType } from './types.ts' export * from './types.ts' @@ -60,7 +61,7 @@ export class Session { /** Set by the store so appends are observable; undefined when detached. */ onAppend: ((event: SessionEvent) => void) | undefined - constructor(public readonly id: string, seed?: SessionEvent[]) { + constructor(public readonly id: SessionId, seed?: SessionEvent[]) { if (seed) this.log = [...seed] } @@ -155,16 +156,16 @@ export class SessionStore extends Service { * session from the store. */ 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) + const sessionId = SessionId(id ?? `session-${++this.counter}`) + if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) + const session = new Session(sessionId, seed) this.ctx.effect(() => { session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } - this.store.set(id, session) + this.store.set(sessionId, session) this.ctx.emit('session/created', session) return () => { session.onAppend = undefined - this.store.delete(id) + this.store.delete(sessionId) } }, 'sessions.create()') return session diff --git a/packages/session/src/types.ts b/packages/session/src/types.ts index 44e904a55c..25628bc3a6 100644 --- a/packages/session/src/types.ts +++ b/packages/session/src/types.ts @@ -1,4 +1,12 @@ -import type { ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { Branded, CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' + +/** Identifies one session in the store (and its persistence artifacts). */ +export type SessionId = Branded<'SessionId'> + +/** Brand a string as a {@link SessionId}. */ +export function SessionId(id: string): SessionId { + return id as SessionId +} /** * What started a turn. @@ -53,8 +61,8 @@ export interface SessionEventMap { '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 } + 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + 'tool/result': { turn: number; step: number; callId: CallId; 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 } diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts index 68e5d69458..44f2bae980 100644 --- a/packages/session/tests/session.spec.ts +++ b/packages/session/tests/session.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SessionStore, { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' describe('Session', () => { it('derives message history from the event log', () => { - const session = new Session('s1') + const session = new Session(SessionId('s1')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }) session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) @@ -12,21 +13,21 @@ describe('Session', () => { turn: 1, step: 1, content: [ { type: 'text', text: 'let me check' }, - { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' }, + { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }, ], }) - session.append('tool/result', { turn: 1, step: 1, callId: 'c1', content: [{ type: 'text', text: 'ok' }], isError: false }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const messages = session.deriveMessages() expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user']) // raw chunks must NOT appear in derived history expect(messages[1]!.content).toHaveLength(2) - expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: 'c1' }) + expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('c1') }) }) it('renders context and steering messages as tagged synthetic user content', () => { - const session = new Session('s2') + const session = new Session(SessionId('s2')) session.append('context/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' }, @@ -45,11 +46,11 @@ describe('Session', () => { }) it('replays identically from a seeded event log', () => { - const original = new Session('s3') + const original = new Session(SessionId('s3')) original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }) - const replayed = new Session('s3-replay', [...original.events]) + const replayed = new Session(SessionId('s3-replay'), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) expect(replayed.seq).toBe(original.seq) }) diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 05b84dda3d..abb6085982 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -8,7 +8,7 @@ */ import { Context, Service } from 'cordis' -import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -54,7 +54,7 @@ export interface ToolDefinition extends ToolSchema { /** One pending tool call, as it flows through the execution waterfall. */ export interface ToolExecution { - callId: string + callId: CallId name: string /** Parsed JSON arguments (unknown — tools validate their own input). */ arguments: unknown @@ -65,7 +65,7 @@ export interface ToolExecution { /** The outcome of one tool call. */ export interface ToolExecutionResult { - callId: string + callId: CallId content: ContentBlock[] isError: boolean } diff --git a/packages/tools/tests/tools.spec.ts b/packages/tools/tests/tools.spec.ts index 40bed2c936..b4500bcfe3 100644 --- a/packages/tools/tests/tools.spec.ts +++ b/packages/tools/tests/tools.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, schemaSpecToJsonSchema, @@ -43,8 +44,8 @@ describe('ToolRegistry', () => { it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) - const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } }) - expect(result).toEqual({ callId: 'c1', content: [{ type: 'text', text: 'hi' }], isError: false }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) }) it('returns isError results for unknown tools and throwing tools', async () => { @@ -57,10 +58,10 @@ describe('ToolRegistry', () => { }, }) - const unknown = await ctx.tools.execute({ callId: 'c1', name: 'nope', arguments: {} }) + const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} }) expect(unknown.isError).toBe(true) - const thrown = await ctx.tools.execute({ callId: 'c2', name: 'boom', arguments: {} }) + const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' }) }) @@ -80,7 +81,7 @@ describe('ToolRegistry', () => { return next() }) - const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'denied by policy' }) }) @@ -103,7 +104,7 @@ describe('ToolRegistry', () => { return result }) - const result = await ctx.tools.execute({ callId: 'c1', name: 'echo', arguments: { text: 'x' } }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } }) expect(result.isError).toBe(false) expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after']) }) @@ -220,7 +221,7 @@ describe('defineTool / schema DSL', () => { }]) const result = await ctx.tools.execute({ - callId: 'c1', + callId: CallId('c1'), name: 'typed-echo', arguments: { text: 'hello', uppercase: true }, }) @@ -274,7 +275,7 @@ describe('defineTool / schema DSL', () => { // Execution round-trip const result = await ctx.tools.execute({ - callId: 'c1', + callId: CallId('c1'), name: 'roundtrip', arguments: { req: 'hello' }, }) @@ -306,7 +307,7 @@ describe('defineTool / schema DSL', () => { }) const result = await ctx.tools.execute({ - callId: 'c1', + callId: CallId('c1'), name: 'raw-tool', arguments: { path: '/tmp' }, }) @@ -519,7 +520,7 @@ describe('schema DSL regressions (Codex review round 2)', () => { throw { message: 'denied by object' } }, }) - const result = await ctx.tools.execute({ callId: 'c1', name: 'object-thrower', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' }) }) @@ -534,7 +535,7 @@ describe('schema DSL regressions (Codex review round 2)', () => { throw 'kaboom' }, }) - const result = await ctx.tools.execute({ callId: 'c1', name: 'string-thrower', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) }) @@ -549,7 +550,7 @@ describe('schema DSL regressions (Codex review round 2)', () => { throw { code: 500 } }, }) - const result = await ctx.tools.execute({ callId: 'c1', name: 'object-no-message', arguments: {} }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} }) expect(result.isError).toBe(true) const firstContent = result.content[0]! expect(firstContent.type).toBe('text')