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:
Tianyi Cui
2026-06-11 15:17:56 +08:00
parent 86955b96a4
commit 225ed051b1
19 changed files with 135 additions and 76 deletions

View File

@@ -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,
) {

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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'),

View File

@@ -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

View File

@@ -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) {