Add branded ID types: CallId, SessionId, AgentId
Nominal string types via a unique-symbol brand (zero runtime cost): an AgentId can no longer be passed where a CallId is expected. Each core package brands the IDs it owns — CallId in dsh-llm (tool-call correlation across blocks, chunks, session events, and execution results), SessionId in dsh-session, AgentId in dsh-agent. Construction goes through same-named factory functions; public string-in APIs (sessions.create, agentLoop.create) keep accepting plain strings and brand internally. Policy note in the brand module: brand IDs that cross package boundaries, not every string.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
32
packages/llm/src/brand.ts
Normal file
32
packages/llm/src/brand.ts
Normal file
@@ -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<B extends string> = 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: '{}' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user