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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: '{}' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user