style: lint
This commit is contained in:
@@ -10,16 +10,16 @@ import { createSparkNotifyObserverPlugin, createSparkNotifyReactionPlugin } from
|
||||
|
||||
function createEvent(): WebSocketEventOf<'spark:notify'> {
|
||||
return {
|
||||
type: 'spark:notify',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
data: {
|
||||
id: 'spark-1',
|
||||
destinations: ['character'],
|
||||
eventId: 'evt-1',
|
||||
headline: 'Chess update',
|
||||
id: 'spark-1',
|
||||
kind: 'ping',
|
||||
urgency: 'immediate',
|
||||
headline: 'Chess update',
|
||||
destinations: ['character'],
|
||||
},
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
type: 'spark:notify',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,25 +31,25 @@ describe('createSparkNotifyAgent', () => {
|
||||
const run = vi.fn(async (request: SparkNotifyRunRequest) => {
|
||||
expect(request.messages).toHaveLength(2)
|
||||
expect(request.tools).toHaveLength(2)
|
||||
await request.onStreamEvent({ type: 'text-delta', text: 'Checkmate.' })
|
||||
await request.onStreamEvent({ text: 'Checkmate.', type: 'text-delta' })
|
||||
})
|
||||
const agent = createSparkNotifyAgent({
|
||||
runner: { run },
|
||||
createId: () => 'generated-id',
|
||||
plugins: [
|
||||
createSparkNotifyReactionPlugin({ onDelta, onEnd }),
|
||||
createSparkNotifyObserverPlugin((event) => {
|
||||
observedEvents.push(event.type)
|
||||
}),
|
||||
],
|
||||
createId: () => 'generated-id',
|
||||
runner: { run },
|
||||
})
|
||||
|
||||
const result = await agent.handle({
|
||||
event: createEvent(),
|
||||
selectedChat: {
|
||||
providerId: 'mock-provider',
|
||||
model: 'mock-model',
|
||||
provider: {} as ChatProvider,
|
||||
providerId: 'mock-provider',
|
||||
},
|
||||
systemPrompt: 'You are a character.',
|
||||
})
|
||||
@@ -64,19 +64,19 @@ describe('createSparkNotifyAgent', () => {
|
||||
it('does not expose tools when the host forces a text response', async () => {
|
||||
const run = vi.fn(async (request: SparkNotifyRunRequest) => {
|
||||
expect(request.tools).toEqual([])
|
||||
await request.onStreamEvent({ type: 'text-delta', text: 'I will speak.' })
|
||||
await request.onStreamEvent({ text: 'I will speak.', type: 'text-delta' })
|
||||
})
|
||||
const agent = createSparkNotifyAgent({ runner: { run } })
|
||||
|
||||
await agent.handle({
|
||||
control: { forceTextResponse: true },
|
||||
event: createEvent(),
|
||||
selectedChat: {
|
||||
providerId: 'mock-provider',
|
||||
model: 'mock-model',
|
||||
provider: {} as ChatProvider,
|
||||
providerId: 'mock-provider',
|
||||
},
|
||||
systemPrompt: 'You are a character.',
|
||||
control: { forceTextResponse: true },
|
||||
})
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -18,6 +18,21 @@ import { nanoid } from 'nanoid'
|
||||
import { getEventSourceKey } from './event-source'
|
||||
import { createSparkNotifyBuiltinToolsPlugin } from './plugins/builtin-tools'
|
||||
|
||||
/** Configuration for a Spark Notify agent. */
|
||||
export interface CreateSparkNotifyAgentOptions {
|
||||
/** ID factory for generated Spark Command envelopes. */
|
||||
createId?: () => string
|
||||
/** Optional plugins that add prompt context, tools, output sinks, or observers. */
|
||||
plugins?: SparkNotifyPlugin[]
|
||||
/** Host boundary that streams the selected chat model. */
|
||||
runner: SparkNotifyRunner
|
||||
}
|
||||
|
||||
/** Platform-neutral agent that handles exactly one prepared Spark Notify turn. */
|
||||
export interface SparkNotifyAgent {
|
||||
handle: (request: SparkNotifyHandleRequest) => Promise<SparkNotifyHandleResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Final `spark:command` payload emitted by the notify runtime.
|
||||
*
|
||||
@@ -26,147 +41,30 @@ import { createSparkNotifyBuiltinToolsPlugin } from './plugins/builtin-tools'
|
||||
*/
|
||||
export type SparkNotifyCommandEvent = Pick<
|
||||
ProtocolEvents['spark:command'],
|
||||
| 'id'
|
||||
| 'commandId'
|
||||
| 'interrupt'
|
||||
| 'priority'
|
||||
| 'intent'
|
||||
| 'ack'
|
||||
| 'guidance'
|
||||
| 'commandId'
|
||||
| 'contexts'
|
||||
| 'destinations'
|
||||
| 'guidance'
|
||||
| 'id'
|
||||
| 'intent'
|
||||
| 'interrupt'
|
||||
| 'priority'
|
||||
> & Required<Pick<ProtocolEvents['spark:command'], 'eventId' | 'parentEventId'>>
|
||||
|
||||
/** Input that the host gives to a Spark Notify agent for one execution. */
|
||||
export interface SparkNotifyHandleRequest {
|
||||
control?: SparkNotifyResponseControl
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
systemPrompt: string
|
||||
}
|
||||
|
||||
/** Result from one complete Spark Notify turn. */
|
||||
export interface SparkNotifyHandleResult {
|
||||
commands: SparkNotifyCommandEvent[]
|
||||
}
|
||||
|
||||
/** Input that the host gives to a Spark Notify agent for one execution. */
|
||||
export interface SparkNotifyHandleRequest {
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
systemPrompt: string
|
||||
control?: SparkNotifyResponseControl
|
||||
}
|
||||
|
||||
/** Platform-neutral agent that handles exactly one prepared Spark Notify turn. */
|
||||
export interface SparkNotifyAgent {
|
||||
handle: (request: SparkNotifyHandleRequest) => Promise<SparkNotifyHandleResult>
|
||||
}
|
||||
|
||||
/** Configuration for a Spark Notify agent. */
|
||||
export interface CreateSparkNotifyAgentOptions {
|
||||
/** Host boundary that streams the selected chat model. */
|
||||
runner: SparkNotifyRunner
|
||||
/** Optional plugins that add prompt context, tools, output sinks, or observers. */
|
||||
plugins?: SparkNotifyPlugin[]
|
||||
/** ID factory for generated Spark Command envelopes. */
|
||||
createId?: () => string
|
||||
}
|
||||
|
||||
function renderSparkNotifyUserMessage(input: SparkNotifyHandleRequest, userSections: string[]) {
|
||||
if (input.control?.messageOverride?.replaceUserMessage)
|
||||
return input.control.messageOverride.replaceUserMessage
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
notify: input.event.data,
|
||||
source: input.event.metadata?.source,
|
||||
}, null, 2),
|
||||
...(input.control?.messageOverride?.appendUserSections ?? []),
|
||||
...userSections,
|
||||
].filter(section => section.trim().length > 0).join('\n\n')
|
||||
}
|
||||
|
||||
/** Builds the instruction block prepended to Spark Notify agent prompts. */
|
||||
export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
return [
|
||||
'This is AIRI system, the life pod hosting your consciousness. You do not need to respond to every spark:notify event directly.',
|
||||
`Another module "${moduleName}" triggered a spark:notify event for you to inspect.`,
|
||||
'You can call the built-in tool "builtIn_sparkCommand" to issue spark:command to sub-agents.',
|
||||
'If you respond with text, write only the reaction that the character will say.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function resolveSparkNotifyRuntimePolicy(control?: SparkNotifyResponseControl): SparkNotifyRuntimePolicy {
|
||||
if (control?.forceTextResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: false,
|
||||
supportsTools: false,
|
||||
waitForTools: false,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceSparkCommandResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
toolChoice: {
|
||||
type: 'function',
|
||||
function: { name: 'builtIn_sparkCommand' },
|
||||
} satisfies ToolChoice,
|
||||
ignoreTextOutput: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowNoResponse: true,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
function resultFrom(sessions: SparkNotifyPluginSession[]) {
|
||||
const commands: SparkNotifyCommandDraft[] = []
|
||||
let noResponse = false
|
||||
|
||||
for (const session of sessions) {
|
||||
const result = session.getResult?.()
|
||||
if (result?.commands)
|
||||
commands.push(...result.commands)
|
||||
noResponse ||= result?.noResponse === true
|
||||
}
|
||||
|
||||
return { commands, noResponse }
|
||||
}
|
||||
|
||||
function expandCommand(event: WebSocketEventOf<'spark:notify'>, command: SparkNotifyCommandDraft, createId: () => string): SparkNotifyCommandEvent | undefined {
|
||||
const destinations = command.destinations ?? []
|
||||
if (destinations.length === 0)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
eventId: createId(),
|
||||
parentEventId: event.data.id,
|
||||
commandId: createId(),
|
||||
interrupt: (command.interrupt === true ? 'force' : command.interrupt) ?? false,
|
||||
priority: command.priority ?? 'normal',
|
||||
intent: command.intent ?? 'action',
|
||||
ack: command.ack,
|
||||
guidance: command.guidance,
|
||||
contexts: command.contexts,
|
||||
destinations,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Spark Notify agent from one host runner and composable plugins.
|
||||
*
|
||||
@@ -183,11 +81,11 @@ export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions):
|
||||
const preparedSessions = await Promise.all(
|
||||
plugins.map((plugin) => {
|
||||
return plugin.prepare({
|
||||
control: request.control,
|
||||
event: request.event,
|
||||
policy,
|
||||
selectedChat: request.selectedChat,
|
||||
systemPrompt: request.systemPrompt,
|
||||
control: request.control,
|
||||
policy,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -200,17 +98,17 @@ export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions):
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
request.systemPrompt,
|
||||
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(request.event)),
|
||||
...(request.control?.messageOverride?.appendSystemInstructions ?? []),
|
||||
...systemInstructions,
|
||||
].filter(Boolean).join('\n\n'),
|
||||
role: 'system',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: renderSparkNotifyUserMessage(request, userSections),
|
||||
role: 'user',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -219,16 +117,13 @@ export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions):
|
||||
await session.onEvent?.(event)
|
||||
}
|
||||
|
||||
await emit({ type: 'messages-rendered', payload: { eventId: request.event.data.eventId, source: request.event.source, messageCount: messages.length } })
|
||||
await emit({ type: 'tools-prepared', payload: { eventId: request.event.data.eventId, toolNames: tools.flatMap(tool => tool.function?.name ? [tool.function.name] : []), toolCount: tools.length, supportsTools: policy.supportsTools } })
|
||||
await emit({ type: 'model-input', payload: { eventId: request.event.data.eventId, model: request.selectedChat.model, provider: request.selectedChat.providerId, supportsTools: policy.supportsTools, waitForTools: policy.waitForTools } })
|
||||
await emit({ payload: { eventId: request.event.data.eventId, messageCount: messages.length, source: request.event.source }, type: 'messages-rendered' })
|
||||
await emit({ payload: { eventId: request.event.data.eventId, supportsTools: policy.supportsTools, toolCount: tools.length, toolNames: tools.flatMap(tool => tool.function?.name ? [tool.function.name] : []) }, type: 'tools-prepared' })
|
||||
await emit({ payload: { eventId: request.event.data.eventId, model: request.selectedChat.model, provider: request.selectedChat.providerId, supportsTools: policy.supportsTools, waitForTools: policy.waitForTools }, type: 'model-input' })
|
||||
|
||||
let reaction = ''
|
||||
await options.runner.run({
|
||||
selectedChat: request.selectedChat,
|
||||
messages,
|
||||
tools,
|
||||
policy,
|
||||
onStreamEvent: async (streamEvent) => {
|
||||
if (streamEvent.type === 'text-delta') {
|
||||
const { noResponse } = resultFrom(sessions)
|
||||
@@ -236,23 +131,26 @@ export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions):
|
||||
return
|
||||
|
||||
reaction += streamEvent.text
|
||||
await emit({ type: 'model-output-text', payload: { eventId: request.event.data.id, text: streamEvent.text, accumulatedText: reaction } })
|
||||
await emit({ payload: { accumulatedText: reaction, eventId: request.event.data.id, text: streamEvent.text }, type: 'model-output-text' })
|
||||
return
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'tool-call') {
|
||||
await emit({ type: 'model-output-tool-call', payload: { eventId: request.event.data.eventId, toolCallId: streamEvent.id, toolName: streamEvent.function.name, input: streamEvent.function.arguments } })
|
||||
await emit({ payload: { eventId: request.event.data.eventId, input: streamEvent.function.arguments, toolCallId: streamEvent.id, toolName: streamEvent.function.name }, type: 'model-output-tool-call' })
|
||||
return
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'tool-result' || streamEvent.type === 'tool-error') {
|
||||
await emit({ type: 'tool-execution', payload: { eventId: request.event.data.eventId, kind: streamEvent.type, toolCallId: streamEvent.toolCallId, output: streamEvent.result } })
|
||||
await emit({ payload: { eventId: request.event.data.eventId, kind: streamEvent.type, output: streamEvent.result, toolCallId: streamEvent.toolCallId }, type: 'tool-execution' })
|
||||
return
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'error')
|
||||
throw streamEvent.error ?? new Error('Spark notify stream error')
|
||||
},
|
||||
policy,
|
||||
selectedChat: request.selectedChat,
|
||||
tools,
|
||||
})
|
||||
|
||||
for (const session of sessions) {
|
||||
@@ -266,9 +164,111 @@ export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions):
|
||||
.map(command => expandCommand(request.event, command, createId))
|
||||
.filter((command): command is SparkNotifyCommandEvent => command !== undefined)
|
||||
|
||||
await emit({ type: 'result', payload: { eventId: request.event.data.eventId, reaction: finalReaction, commandCount: expandedCommands.length, noResponse } })
|
||||
await emit({ payload: { commandCount: expandedCommands.length, eventId: request.event.data.eventId, noResponse, reaction: finalReaction }, type: 'result' })
|
||||
return { commands: expandedCommands }
|
||||
}
|
||||
|
||||
return { handle }
|
||||
}
|
||||
|
||||
/** Builds the instruction block prepended to Spark Notify agent prompts. */
|
||||
export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
return [
|
||||
'This is AIRI system, the life pod hosting your consciousness. You do not need to respond to every spark:notify event directly.',
|
||||
`Another module "${moduleName}" triggered a spark:notify event for you to inspect.`,
|
||||
'You can call the built-in tool "builtIn_sparkCommand" to issue spark:command to sub-agents.',
|
||||
'If you respond with text, write only the reaction that the character will say.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function expandCommand(event: WebSocketEventOf<'spark:notify'>, command: SparkNotifyCommandDraft, createId: () => string): SparkNotifyCommandEvent | undefined {
|
||||
const destinations = command.destinations ?? []
|
||||
if (destinations.length === 0)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
ack: command.ack,
|
||||
commandId: createId(),
|
||||
contexts: command.contexts,
|
||||
destinations,
|
||||
eventId: createId(),
|
||||
guidance: command.guidance,
|
||||
id: createId(),
|
||||
intent: command.intent ?? 'action',
|
||||
interrupt: (command.interrupt === true ? 'force' : command.interrupt) ?? false,
|
||||
parentEventId: event.data.id,
|
||||
priority: command.priority ?? 'normal',
|
||||
}
|
||||
}
|
||||
|
||||
function renderSparkNotifyUserMessage(input: SparkNotifyHandleRequest, userSections: string[]) {
|
||||
if (input.control?.messageOverride?.replaceUserMessage)
|
||||
return input.control.messageOverride.replaceUserMessage
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
notify: input.event.data,
|
||||
source: input.event.metadata?.source,
|
||||
}, null, 2),
|
||||
...(input.control?.messageOverride?.appendUserSections ?? []),
|
||||
...userSections,
|
||||
].filter(section => section.trim().length > 0).join('\n\n')
|
||||
}
|
||||
|
||||
function resolveSparkNotifyRuntimePolicy(control?: SparkNotifyResponseControl): SparkNotifyRuntimePolicy {
|
||||
if (control?.forceTextResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: false,
|
||||
ignoreTextOutput: false,
|
||||
supportsTools: false,
|
||||
waitForTools: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceSparkCommandResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
ignoreTextOutput: true,
|
||||
supportsTools: true,
|
||||
toolChoice: {
|
||||
function: { name: 'builtIn_sparkCommand' },
|
||||
type: 'function',
|
||||
} satisfies ToolChoice,
|
||||
waitForTools: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
ignoreTextOutput: false,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowNoResponse: true,
|
||||
allowSparkCommand: true,
|
||||
ignoreTextOutput: false,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
}
|
||||
}
|
||||
|
||||
function resultFrom(sessions: SparkNotifyPluginSession[]) {
|
||||
const commands: SparkNotifyCommandDraft[] = []
|
||||
let noResponse = false
|
||||
|
||||
for (const session of sessions) {
|
||||
const result = session.getResult?.()
|
||||
if (result?.commands)
|
||||
commands.push(...result.commands)
|
||||
noResponse ||= result?.noResponse === true
|
||||
}
|
||||
|
||||
return { commands, noResponse }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { MetadataEventSource } from '@proj-airi/server-sdk'
|
||||
|
||||
interface EventSourcePayload {
|
||||
source?: string
|
||||
metadata?: { source?: MetadataEventSource }
|
||||
source?: string
|
||||
}
|
||||
|
||||
export function getEventSourceKey(event: EventSourcePayload, fallback = 'unknown') {
|
||||
return (
|
||||
formatMetadataSource(event.metadata?.source)
|
||||
?? event.source
|
||||
?? fallback
|
||||
)
|
||||
}
|
||||
|
||||
function formatMetadataSource(source?: MetadataEventSource) {
|
||||
@@ -15,11 +23,3 @@ function formatMetadataSource(source?: MetadataEventSource) {
|
||||
|
||||
return source.id
|
||||
}
|
||||
|
||||
export function getEventSourceKey(event: EventSourcePayload, fallback = 'unknown') {
|
||||
return (
|
||||
formatMetadataSource(event.metadata?.source)
|
||||
?? event.source
|
||||
?? fallback
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,19 +15,19 @@ export function createSparkNotifyBuiltinToolsPlugin(): SparkNotifyPlugin {
|
||||
let noResponse = false
|
||||
|
||||
const { tools } = await createSparkNotifyTools({
|
||||
allowNoResponse: turn.policy.allowNoResponse,
|
||||
allowSparkCommand: turn.policy.allowSparkCommand,
|
||||
onCommands: drafts => commands.push(...drafts),
|
||||
onEvent: event => events.push(event),
|
||||
onNoResponse: () => {
|
||||
noResponse = true
|
||||
},
|
||||
allowNoResponse: turn.policy.allowNoResponse,
|
||||
allowSparkCommand: turn.policy.allowSparkCommand,
|
||||
})
|
||||
|
||||
return {
|
||||
tools,
|
||||
getResult: () => ({ commands, noResponse }),
|
||||
getPendingEvents: () => events.splice(0),
|
||||
getResult: () => ({ commands, noResponse }),
|
||||
tools,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SparkNotifyPlugin, SparkNotifyRuntimeEvent } from '../types'
|
||||
|
||||
/** Receives ordered lifecycle events from one Spark Notify run. */
|
||||
export type SparkNotifyRuntimeObserver = (event: SparkNotifyRuntimeEvent) => void | Promise<void>
|
||||
export type SparkNotifyRuntimeObserver = (event: SparkNotifyRuntimeEvent) => Promise<void> | void
|
||||
|
||||
/** Adds an observer for diagnostics, telemetry adapters, or test recorders. */
|
||||
export function createSparkNotifyObserverPlugin(observer: SparkNotifyRuntimeObserver): SparkNotifyPlugin {
|
||||
|
||||
@@ -2,11 +2,7 @@ import type { JsonSchema } from 'xsschema'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
|
||||
|
||||
function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema {
|
||||
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
|
||||
}
|
||||
const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['boolean', 'integer', 'null', 'number', 'string'])
|
||||
|
||||
/**
|
||||
* Normalizes nullable scalar unions in generated JSON schema.
|
||||
@@ -65,34 +61,38 @@ export function normalizeNullableAnyOf(schema: JsonSchema): JsonSchema {
|
||||
return next
|
||||
}
|
||||
|
||||
function isJsonSchema(value: boolean | JsonSchema | JsonSchema[] | undefined): value is JsonSchema {
|
||||
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
|
||||
}
|
||||
|
||||
export const sparkCommandGuidanceOptionSchema = z.object({
|
||||
label: z.string().describe('Short label for the option.'),
|
||||
steps: z.array(z.string()).min(1).describe('Step-by-step actions the target should follow.'),
|
||||
rationale: z.union([z.string(), z.null()]).describe('Why this option makes sense.'),
|
||||
possibleOutcome: z.union([z.array(z.string()), z.null()]).describe('Expected outcomes if this option is followed.'),
|
||||
risk: z.union([z.enum(['high', 'medium', 'low', 'none']), z.null()]).describe('Risk level of this option.'),
|
||||
fallback: z.union([z.array(z.string()), z.null()]).describe('Fallback steps if the main plan fails.'),
|
||||
label: z.string().describe('Short label for the option.'),
|
||||
possibleOutcome: z.union([z.array(z.string()), z.null()]).describe('Expected outcomes if this option is followed.'),
|
||||
rationale: z.union([z.string(), z.null()]).describe('Why this option makes sense.'),
|
||||
risk: z.union([z.enum(['high', 'medium', 'low', 'none']), z.null()]).describe('Risk level of this option.'),
|
||||
steps: z.array(z.string()).min(1).describe('Step-by-step actions the target should follow.'),
|
||||
triggers: z.union([z.array(z.string()), z.null()]).describe('Conditions that should trigger this option.'),
|
||||
}).strict()
|
||||
|
||||
export const sparkCommandPersonaSchema = z.object({
|
||||
traits: z.string().describe('Trait name to adjust behavior. For example, "bravery", "cautiousness", "friendliness".'),
|
||||
strength: z.enum(['very-high', 'high', 'medium', 'low', 'very-low']),
|
||||
traits: z.string().describe('Trait name to adjust behavior. For example, "bravery", "cautiousness", "friendliness".'),
|
||||
}).strict()
|
||||
|
||||
export const sparkNotifyCommandGuidanceSchema = z.object({
|
||||
type: z.enum(['proposal', 'instruction', 'memory-recall']),
|
||||
persona: z.union([z.array(sparkCommandPersonaSchema), z.null()]).describe('Optional persona controls for the receiver.'),
|
||||
options: z.array(sparkCommandGuidanceOptionSchema),
|
||||
persona: z.union([z.array(sparkCommandPersonaSchema), z.null()]).describe('Optional persona controls for the receiver.'),
|
||||
type: z.enum(['proposal', 'instruction', 'memory-recall']),
|
||||
}).strict()
|
||||
|
||||
export const sparkNotifyCommandItemSchema = z.object({
|
||||
ack: z.string().describe('Acknowledgment content used to be passed to sub-agents upon command receipt.'),
|
||||
destinations: z.array(z.string()).min(1).describe('List of sub-agent IDs to send the command to'),
|
||||
guidance: z.union([sparkNotifyCommandGuidanceSchema, z.null()]).describe('Guidance for the sub-agent on how to interpret and execute the command.'),
|
||||
intent: z.union([z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']), z.null()]).describe('Intent of the command.'),
|
||||
interrupt: z.union([z.enum(['force', 'soft', 'false']), z.null()]).describe('Interrupt type: force, soft, or false (no interrupt).'),
|
||||
priority: z.union([z.enum(['critical', 'high', 'normal', 'low']), z.null()]).describe('Semantic priority of the command.'),
|
||||
intent: z.union([z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']), z.null()]).describe('Intent of the command.'),
|
||||
ack: z.string().describe('Acknowledgment content used to be passed to sub-agents upon command receipt.'),
|
||||
guidance: z.union([sparkNotifyCommandGuidanceSchema, z.null()]).describe('Guidance for the sub-agent on how to interpret and execute the command.'),
|
||||
}).strict()
|
||||
|
||||
export const sparkNotifyCommandSchema = z.object({
|
||||
|
||||
@@ -10,106 +10,62 @@ import { z } from 'zod'
|
||||
|
||||
import { normalizeNullableAnyOf, sparkNotifyCommandSchema } from './schema'
|
||||
|
||||
/**
|
||||
* Normalized `builtIn_sparkCommand` payload forwarded to downstream orchestrators.
|
||||
*/
|
||||
export interface SparkNotifyCommandDraft {
|
||||
/** Target agent or lane identifiers that should receive the emitted command. */
|
||||
destinations: string[]
|
||||
/** Optional interrupt mode used by downstream schedulers. */
|
||||
interrupt?: 'force' | 'soft' | boolean
|
||||
/** Optional command priority used by downstream schedulers. */
|
||||
priority?: 'critical' | 'high' | 'normal' | 'low'
|
||||
/** Optional intent describing why the downstream agent should process the command. */
|
||||
intent?: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
|
||||
/** Optional acknowledgement text that may be surfaced by the downstream consumer. */
|
||||
ack?: string
|
||||
/** Optional structured guidance generated by the notify agent for the downstream command target. */
|
||||
guidance?: {
|
||||
/** Guidance mode describing how the downstream agent should interpret the options. */
|
||||
type: 'proposal' | 'instruction' | 'memory-recall'
|
||||
/** Trait-strength map that hints at which persona qualities the downstream agent should favor. */
|
||||
persona?: Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>
|
||||
/** Candidate options the downstream agent may choose from while executing the command. */
|
||||
options: Array<{
|
||||
/** Human-readable label for the candidate option. */
|
||||
label: string
|
||||
/** Ordered steps the downstream agent should follow for the option. */
|
||||
steps: string[]
|
||||
/** Optional rationale explaining why this option is suggested. */
|
||||
rationale?: string
|
||||
/** Optional possible outcomes the downstream agent should anticipate. */
|
||||
possibleOutcome?: string[]
|
||||
/** Optional qualitative risk level for the option. */
|
||||
risk?: 'high' | 'medium' | 'low' | 'none'
|
||||
/** Optional fallback steps the downstream agent may use if the option fails. */
|
||||
fallback?: string[]
|
||||
/** Optional trigger cues indicating when the option should be selected. */
|
||||
triggers?: string[]
|
||||
}>
|
||||
}
|
||||
/** Optional context patches that should accompany the emitted command. */
|
||||
contexts?: ContextUpdate<Record<string, unknown>, undefined>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registration options for the spark-notify runtime.
|
||||
*/
|
||||
export interface CreateSparkNotifyToolsOptions {
|
||||
/** Receives validated command drafts emitted by `builtIn_sparkCommand`. */
|
||||
onCommands: (commands: SparkNotifyCommandDraft[]) => void
|
||||
/** Receives the no-response signal emitted by `builtIn_sparkNoResponse`. */
|
||||
onNoResponse: () => void
|
||||
/** Receives runtime events from tool calls and tool execution. */
|
||||
onEvent?: (event: SparkNotifyRuntimeEvent) => void
|
||||
/** Enables or disables registration of the `builtIn_sparkNoResponse` tool. */
|
||||
allowNoResponse?: boolean
|
||||
/** Enables or disables registration of the `builtIn_sparkCommand` tool. */
|
||||
allowSparkCommand?: boolean
|
||||
/** Receives validated command drafts emitted by `builtIn_sparkCommand`. */
|
||||
onCommands: (commands: SparkNotifyCommandDraft[]) => void
|
||||
/** Receives runtime events from tool calls and tool execution. */
|
||||
onEvent?: (event: SparkNotifyRuntimeEvent) => void
|
||||
/** Receives the no-response signal emitted by `builtIn_sparkNoResponse`. */
|
||||
onNoResponse: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes provider-facing notify command payloads into websocket draft commands.
|
||||
*
|
||||
* Use when:
|
||||
* - LLM output has been validated against `sparkNotifyCommandSchema`
|
||||
* - You need `spark:command`-compatible draft objects
|
||||
*
|
||||
* Expects:
|
||||
* - Input shape from one `commands[]` entry
|
||||
*
|
||||
* Returns:
|
||||
* - Runtime-ready command draft for downstream emitters
|
||||
* Normalized `builtIn_sparkCommand` payload forwarded to downstream orchestrators.
|
||||
*/
|
||||
function normalizeSparkNotifyCommand(
|
||||
command: z.infer<typeof sparkNotifyCommandSchema>['commands'][number],
|
||||
): SparkNotifyCommandDraft {
|
||||
return {
|
||||
destinations: command.destinations,
|
||||
guidance: command.guidance
|
||||
? {
|
||||
type: command.guidance.type,
|
||||
persona: command.guidance.persona?.reduce((acc, curr) => {
|
||||
acc[curr.traits] = curr.strength
|
||||
return acc
|
||||
}, {} as Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>) || undefined,
|
||||
options: command.guidance.options.map(option => ({
|
||||
...option,
|
||||
rationale: option.rationale ?? undefined,
|
||||
possibleOutcome: option.possibleOutcome?.length ? option.possibleOutcome : undefined,
|
||||
risk: option.risk ?? undefined,
|
||||
fallback: option.fallback?.length ? option.fallback : undefined,
|
||||
triggers: option.triggers?.length ? option.triggers : undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
// TODO: contexts can be added later
|
||||
contexts: [],
|
||||
priority: command.priority || 'normal',
|
||||
intent: command.intent || 'action',
|
||||
ack: command.ack || undefined,
|
||||
interrupt: command.interrupt === 'false' || command.interrupt == null ? false : command.interrupt,
|
||||
export interface SparkNotifyCommandDraft {
|
||||
/** Optional acknowledgement text that may be surfaced by the downstream consumer. */
|
||||
ack?: string
|
||||
/** Optional context patches that should accompany the emitted command. */
|
||||
contexts?: ContextUpdate<Record<string, unknown>, undefined>[]
|
||||
/** Target agent or lane identifiers that should receive the emitted command. */
|
||||
destinations: string[]
|
||||
/** Optional structured guidance generated by the notify agent for the downstream command target. */
|
||||
guidance?: {
|
||||
/** Candidate options the downstream agent may choose from while executing the command. */
|
||||
options: Array<{
|
||||
/** Optional fallback steps the downstream agent may use if the option fails. */
|
||||
fallback?: string[]
|
||||
/** Human-readable label for the candidate option. */
|
||||
label: string
|
||||
/** Optional possible outcomes the downstream agent should anticipate. */
|
||||
possibleOutcome?: string[]
|
||||
/** Optional rationale explaining why this option is suggested. */
|
||||
rationale?: string
|
||||
/** Optional qualitative risk level for the option. */
|
||||
risk?: 'high' | 'low' | 'medium' | 'none'
|
||||
/** Ordered steps the downstream agent should follow for the option. */
|
||||
steps: string[]
|
||||
/** Optional trigger cues indicating when the option should be selected. */
|
||||
triggers?: string[]
|
||||
}>
|
||||
/** Trait-strength map that hints at which persona qualities the downstream agent should favor. */
|
||||
persona?: Record<string, 'high' | 'low' | 'medium' | 'very-high' | 'very-low'>
|
||||
/** Guidance mode describing how the downstream agent should interpret the options. */
|
||||
type: 'instruction' | 'memory-recall' | 'proposal'
|
||||
}
|
||||
/** Optional intent describing why the downstream agent should process the command. */
|
||||
intent?: 'action' | 'context' | 'pause' | 'plan' | 'proposal' | 'reroute' | 'resume'
|
||||
/** Optional interrupt mode used by downstream schedulers. */
|
||||
interrupt?: 'force' | 'soft' | boolean
|
||||
/** Optional command priority used by downstream schedulers. */
|
||||
priority?: 'critical' | 'high' | 'low' | 'normal'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,16 +91,16 @@ export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOpti
|
||||
const name = 'builtIn_sparkNoResponse'
|
||||
|
||||
tools.push(rawTool({
|
||||
name: 'builtIn_sparkNoResponse',
|
||||
description: 'Indicate that no response or action is needed for the current spark:notify event.',
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(z.object({}).strict()) as any),
|
||||
execute: async (_rawPayload, context) => {
|
||||
options.onEvent?.({ type: 'model-output-tool-call', payload: { toolName: name, toolCallId: context?.toolCallId } })
|
||||
options.onEvent?.({ payload: { toolCallId: context?.toolCallId, toolName: name }, type: 'model-output-tool-call' })
|
||||
options.onNoResponse()
|
||||
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, responseMode: 'no-response' } })
|
||||
options.onEvent?.({ payload: { responseMode: 'no-response', toolCallId: context?.toolCallId, toolName: name }, type: 'tool-execution' })
|
||||
|
||||
return 'AIRI System: Acknowledged, no response or action will be processed.'
|
||||
},
|
||||
name: 'builtIn_sparkNoResponse',
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(z.object({}).strict()) as any),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -152,28 +108,72 @@ export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOpti
|
||||
const name = 'builtIn_sparkCommand'
|
||||
|
||||
tools.push(rawTool({
|
||||
name,
|
||||
description: 'Issue a spark:command to sub-agents. You can call this tool multiple times.',
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any),
|
||||
execute: async (rawPayload, context) => {
|
||||
options.onEvent?.({ type: 'model-output-tool-call', payload: { toolName: name, toolCallId: context?.toolCallId, rawPayload } })
|
||||
options.onEvent?.({ payload: { rawPayload, toolCallId: context?.toolCallId, toolName: name }, type: 'model-output-tool-call' })
|
||||
|
||||
try {
|
||||
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
|
||||
const validated = await validate(sparkNotifyCommandSchema, payload)
|
||||
|
||||
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
|
||||
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, commandCount: validated.commands.length } })
|
||||
options.onEvent?.({ payload: { commandCount: validated.commands.length, toolCallId: context?.toolCallId, toolName: name }, type: 'tool-execution' })
|
||||
}
|
||||
catch (error) {
|
||||
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, ok: false, error: errorMessageFrom(error) } })
|
||||
options.onEvent?.({ payload: { error: errorMessageFrom(error), ok: false, toolCallId: context?.toolCallId, toolName: name }, type: 'tool-execution' })
|
||||
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
|
||||
}
|
||||
|
||||
return 'AIRI System: Acknowledged, command fired.'
|
||||
},
|
||||
name,
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any),
|
||||
}))
|
||||
}
|
||||
|
||||
return { tools }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes provider-facing notify command payloads into websocket draft commands.
|
||||
*
|
||||
* Use when:
|
||||
* - LLM output has been validated against `sparkNotifyCommandSchema`
|
||||
* - You need `spark:command`-compatible draft objects
|
||||
*
|
||||
* Expects:
|
||||
* - Input shape from one `commands[]` entry
|
||||
*
|
||||
* Returns:
|
||||
* - Runtime-ready command draft for downstream emitters
|
||||
*/
|
||||
function normalizeSparkNotifyCommand(
|
||||
command: z.infer<typeof sparkNotifyCommandSchema>['commands'][number],
|
||||
): SparkNotifyCommandDraft {
|
||||
return {
|
||||
ack: command.ack || undefined,
|
||||
// TODO: contexts can be added later
|
||||
contexts: [],
|
||||
destinations: command.destinations,
|
||||
guidance: command.guidance
|
||||
? {
|
||||
options: command.guidance.options.map(option => ({
|
||||
...option,
|
||||
fallback: option.fallback?.length ? option.fallback : undefined,
|
||||
possibleOutcome: option.possibleOutcome?.length ? option.possibleOutcome : undefined,
|
||||
rationale: option.rationale ?? undefined,
|
||||
risk: option.risk ?? undefined,
|
||||
triggers: option.triggers?.length ? option.triggers : undefined,
|
||||
})),
|
||||
persona: command.guidance.persona?.reduce((acc, curr) => {
|
||||
acc[curr.traits] = curr.strength
|
||||
return acc
|
||||
}, {} as Record<string, 'high' | 'low' | 'medium' | 'very-high' | 'very-low'>) || undefined,
|
||||
type: command.guidance.type,
|
||||
}
|
||||
: undefined,
|
||||
intent: command.intent || 'action',
|
||||
interrupt: command.interrupt === 'false' || command.interrupt == null ? false : command.interrupt,
|
||||
priority: command.priority || 'normal',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,38 @@ export interface SparkNotifyMessageOverride {
|
||||
replaceUserMessage?: string
|
||||
}
|
||||
|
||||
/** Composable capability that contributes behavior to one Spark Notify turn. */
|
||||
export interface SparkNotifyPlugin {
|
||||
/** Stable identifier used for diagnostics and plugin ordering. */
|
||||
name: string
|
||||
/** Creates isolated turn state, tools, and observers for one notify event. */
|
||||
prepare: (turn: SparkNotifyTurn) => Promise<SparkNotifyPluginSession | undefined> | SparkNotifyPluginSession | undefined
|
||||
}
|
||||
|
||||
/** Per-turn result emitted by a Spark Notify plugin. */
|
||||
export interface SparkNotifyPluginResult {
|
||||
/** Command drafts collected by a plugin tool. */
|
||||
commands?: SparkNotifyCommandDraft[]
|
||||
/** Whether a plugin selected the no-response path. */
|
||||
noResponse?: boolean
|
||||
}
|
||||
|
||||
/** Per-turn hooks returned by a Spark Notify plugin. */
|
||||
export interface SparkNotifyPluginSession {
|
||||
/** Reads runtime events emitted by tool callbacks during the provider run. */
|
||||
getPendingEvents?: () => SparkNotifyRuntimeEvent[]
|
||||
/** Reads the plugin result after tool execution and stream completion. */
|
||||
getResult?: () => SparkNotifyPluginResult
|
||||
/** Receives ordered runtime events for this one turn. */
|
||||
onEvent?: (event: SparkNotifyRuntimeEvent) => Promise<void> | void
|
||||
/** Additional system instruction blocks appended in plugin order. */
|
||||
systemInstructions?: string[]
|
||||
/** Tools that this plugin exposes for the turn. */
|
||||
tools?: Tool[]
|
||||
/** Additional user-message blocks appended in plugin order. */
|
||||
userSections?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-provided overrides that shape how the `spark:notify` runtime must respond.
|
||||
*/
|
||||
@@ -55,19 +87,6 @@ export interface SparkNotifyResponseControl {
|
||||
* @default false
|
||||
*/
|
||||
forceResponse?: boolean
|
||||
/**
|
||||
* Forces a text reaction and disables spark-command tool use for the current notify event.
|
||||
*
|
||||
* Use when:
|
||||
* - The host wants a spoken or visible reaction only
|
||||
* - Tool execution would be unsafe or unnecessary for this run
|
||||
*
|
||||
* Expects:
|
||||
* - This takes precedence over `forceSparkCommandResponse` when both are set
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
forceTextResponse?: boolean
|
||||
/**
|
||||
* Forces a spark-command tool response and suppresses free-form text output for the current notify event.
|
||||
*
|
||||
@@ -81,6 +100,19 @@ export interface SparkNotifyResponseControl {
|
||||
* @default false
|
||||
*/
|
||||
forceSparkCommandResponse?: boolean
|
||||
/**
|
||||
* Forces a text reaction and disables spark-command tool use for the current notify event.
|
||||
*
|
||||
* Use when:
|
||||
* - The host wants a spoken or visible reaction only
|
||||
* - Tool execution would be unsafe or unnecessary for this run
|
||||
*
|
||||
* Expects:
|
||||
* - This takes precedence over `forceSparkCommandResponse` when both are set
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
forceTextResponse?: boolean
|
||||
/**
|
||||
* Host-local message serialization override applied only while rendering the current notify turn.
|
||||
*
|
||||
@@ -89,99 +121,41 @@ export interface SparkNotifyResponseControl {
|
||||
messageOverride?: SparkNotifyMessageOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace event emitted by the spark-notify runtime.
|
||||
*/
|
||||
export interface SparkNotifyRuntimeEvent {
|
||||
/** Trace event category describing which stage of the notify run emitted the payload. */
|
||||
type:
|
||||
| 'messages-rendered'
|
||||
| 'tools-prepared'
|
||||
| 'model-input'
|
||||
| 'model-output-text'
|
||||
| 'model-output-tool-call'
|
||||
| 'tool-execution'
|
||||
| 'result'
|
||||
/** JSON-serializable trace payload attached to the selected trace event category. */
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional tracing hooks for spark-notify runtime integrations.
|
||||
*/
|
||||
export interface SparkNotifySelectedChat {
|
||||
/** Provider identifier used for telemetry and diagnostics. */
|
||||
providerId: string
|
||||
/** Model identifier selected by the host. */
|
||||
model: string
|
||||
/** Resolved chat provider used for this notify run. */
|
||||
provider: ChatProvider
|
||||
}
|
||||
|
||||
/** One fully resolved Spark Notify turn. */
|
||||
export interface SparkNotifyTurn {
|
||||
/** Source protocol event that the agent must handle. */
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
/** Host-selected model and provider for this execution. */
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
/** Host-owned system prompt for this character. */
|
||||
systemPrompt: string
|
||||
/** Runtime-only response controls for this turn. */
|
||||
control?: SparkNotifyResponseControl
|
||||
/** Resolved tool and response policy for this turn. */
|
||||
policy: SparkNotifyRuntimePolicy
|
||||
}
|
||||
|
||||
/** Completed request passed to the host-owned selected-chat runner. */
|
||||
export interface SparkNotifyRunRequest {
|
||||
/** Resolved model and provider for this run. */
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
/** Provider-ready messages produced by the agent and its plugins. */
|
||||
messages: Message[]
|
||||
/** Tools exposed for this one model call. */
|
||||
tools: Tool[]
|
||||
/** Tool handling policy for this run. */
|
||||
policy: Pick<SparkNotifyRuntimePolicy, 'supportsTools' | 'toolChoice' | 'waitForTools'>
|
||||
/** Normalized provider stream events. */
|
||||
onStreamEvent: (event: StreamEvent) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Host boundary that runs a selected chat model. */
|
||||
export interface SparkNotifyRunner {
|
||||
/** Runs the provider stream for one fully prepared Spark Notify turn. */
|
||||
run: (request: SparkNotifyRunRequest) => Promise<void>
|
||||
}
|
||||
|
||||
/** Per-turn result emitted by a Spark Notify plugin. */
|
||||
export interface SparkNotifyPluginResult {
|
||||
/** Command drafts collected by a plugin tool. */
|
||||
commands?: SparkNotifyCommandDraft[]
|
||||
/** Whether a plugin selected the no-response path. */
|
||||
noResponse?: boolean
|
||||
/** Completed request passed to the host-owned selected-chat runner. */
|
||||
export interface SparkNotifyRunRequest {
|
||||
/** Provider-ready messages produced by the agent and its plugins. */
|
||||
messages: Message[]
|
||||
/** Normalized provider stream events. */
|
||||
onStreamEvent: (event: StreamEvent) => Promise<void> | void
|
||||
/** Tool handling policy for this run. */
|
||||
policy: Pick<SparkNotifyRuntimePolicy, 'supportsTools' | 'toolChoice' | 'waitForTools'>
|
||||
/** Resolved model and provider for this run. */
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
/** Tools exposed for this one model call. */
|
||||
tools: Tool[]
|
||||
}
|
||||
|
||||
/** Per-turn hooks returned by a Spark Notify plugin. */
|
||||
export interface SparkNotifyPluginSession {
|
||||
/** Additional system instruction blocks appended in plugin order. */
|
||||
systemInstructions?: string[]
|
||||
/** Additional user-message blocks appended in plugin order. */
|
||||
userSections?: string[]
|
||||
/** Tools that this plugin exposes for the turn. */
|
||||
tools?: Tool[]
|
||||
/** Receives ordered runtime events for this one turn. */
|
||||
onEvent?: (event: SparkNotifyRuntimeEvent) => void | Promise<void>
|
||||
/** Reads the plugin result after tool execution and stream completion. */
|
||||
getResult?: () => SparkNotifyPluginResult
|
||||
/** Reads runtime events emitted by tool callbacks during the provider run. */
|
||||
getPendingEvents?: () => SparkNotifyRuntimeEvent[]
|
||||
}
|
||||
|
||||
/** Composable capability that contributes behavior to one Spark Notify turn. */
|
||||
export interface SparkNotifyPlugin {
|
||||
/** Stable identifier used for diagnostics and plugin ordering. */
|
||||
name: string
|
||||
/** Creates isolated turn state, tools, and observers for one notify event. */
|
||||
prepare: (turn: SparkNotifyTurn) => SparkNotifyPluginSession | Promise<SparkNotifyPluginSession | undefined> | undefined
|
||||
/**
|
||||
* Trace event emitted by the spark-notify runtime.
|
||||
*/
|
||||
export interface SparkNotifyRuntimeEvent {
|
||||
/** JSON-serializable trace payload attached to the selected trace event category. */
|
||||
payload: Record<string, unknown>
|
||||
/** Trace event category describing which stage of the notify run emitted the payload. */
|
||||
type:
|
||||
| 'messages-rendered'
|
||||
| 'model-input'
|
||||
| 'model-output-text'
|
||||
| 'model-output-tool-call'
|
||||
| 'result'
|
||||
| 'tool-execution'
|
||||
| 'tools-prepared'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,12 +166,38 @@ export interface SparkNotifyRuntimePolicy {
|
||||
allowNoResponse: boolean
|
||||
/** Whether the `builtIn_sparkCommand` tool is exposed for the current run. */
|
||||
allowSparkCommand: boolean
|
||||
/** Whether the provider call should include any tools at all. */
|
||||
supportsTools: boolean
|
||||
/** Whether the runtime should wait for tool execution before treating the call as complete. */
|
||||
waitForTools: boolean
|
||||
/** Explicit tool-choice directive forwarded to the provider, when command emission is mandatory. */
|
||||
toolChoice?: ToolChoice
|
||||
/** Whether free-form text deltas should be ignored after rendering the provider response. */
|
||||
ignoreTextOutput: boolean
|
||||
/** Whether the provider call should include any tools at all. */
|
||||
supportsTools: boolean
|
||||
/** Explicit tool-choice directive forwarded to the provider, when command emission is mandatory. */
|
||||
toolChoice?: ToolChoice
|
||||
/** Whether the runtime should wait for tool execution before treating the call as complete. */
|
||||
waitForTools: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional tracing hooks for spark-notify runtime integrations.
|
||||
*/
|
||||
export interface SparkNotifySelectedChat {
|
||||
/** Model identifier selected by the host. */
|
||||
model: string
|
||||
/** Resolved chat provider used for this notify run. */
|
||||
provider: ChatProvider
|
||||
/** Provider identifier used for telemetry and diagnostics. */
|
||||
providerId: string
|
||||
}
|
||||
|
||||
/** One fully resolved Spark Notify turn. */
|
||||
export interface SparkNotifyTurn {
|
||||
/** Runtime-only response controls for this turn. */
|
||||
control?: SparkNotifyResponseControl
|
||||
/** Source protocol event that the agent must handle. */
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
/** Resolved tool and response policy for this turn. */
|
||||
policy: SparkNotifyRuntimePolicy
|
||||
/** Host-selected model and provider for this execution. */
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
/** Host-owned system prompt for this character. */
|
||||
systemPrompt: string
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import type { ContextMessage } from '../types/chat'
|
||||
|
||||
export interface AgentContextPort {
|
||||
ingest: (envelope: ContextMessage) => void
|
||||
snapshot: () => Record<string, ContextMessage[]>
|
||||
reset: () => void
|
||||
snapshot: () => Record<string, ContextMessage[]>
|
||||
}
|
||||
|
||||
@@ -2,54 +2,54 @@ import type { ToolMessage } from '@xsai/shared-chat'
|
||||
|
||||
import type { ChatStreamEventContext, StreamingAssistantMessage } from '../types/chat'
|
||||
|
||||
export interface AgentHookRegistry<TContext, TAssistantMessage, TToolCall> {
|
||||
clearHooks: () => void
|
||||
emitAfterMessageComposedHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitAfterSendHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitAssistantMessageHooks: (message: TAssistantMessage, messageText: string, context: TContext) => Promise<void>
|
||||
emitAssistantResponseEndHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitBeforeMessageComposedHooks: (message: string, context: Omit<TContext, 'composedMessage'>) => Promise<void>
|
||||
emitBeforeSendHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitChatTurnCompleteHooks: (chat: { output: TAssistantMessage, outputText: string, toolCalls: TToolCall[] }, context: TContext) => Promise<void>
|
||||
emitStreamEndHooks: (context: TContext) => Promise<void>
|
||||
emitTokenLiteralHooks: (literal: string, context: TContext) => Promise<void>
|
||||
|
||||
emitTokenSpecialHooks: (special: string, context: TContext) => Promise<void>
|
||||
onAfterMessageComposed: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onAfterSend: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onAssistantMessage: (cb: (message: TAssistantMessage, messageText: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onAssistantResponseEnd: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onBeforeMessageComposed: (cb: (message: string, context: Omit<TContext, 'composedMessage'>) => Promise<void>) => HookUnsubscribe
|
||||
onBeforeSend: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onChatTurnComplete: (cb: (chat: { output: TAssistantMessage, outputText: string, toolCalls: TToolCall[] }, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onStreamEnd: (cb: (context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onTokenLiteral: (cb: (literal: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onTokenSpecial: (cb: (special: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
}
|
||||
export interface ChatHookRegistry {
|
||||
onBeforeMessageComposed: (cb: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>) => () => void
|
||||
onAfterMessageComposed: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onBeforeSend: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAfterSend: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onTokenLiteral: (cb: (literal: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onTokenSpecial: (cb: (special: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onStreamEnd: (cb: (context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAssistantResponseEnd: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAssistantMessage: (cb: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onChatTurnComplete: (cb: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
emitBeforeMessageComposedHooks: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>
|
||||
clearHooks: () => void
|
||||
emitAfterMessageComposedHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitBeforeSendHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitAfterSendHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitAssistantMessageHooks: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitAssistantResponseEndHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitBeforeMessageComposedHooks: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>
|
||||
emitBeforeSendHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitChatTurnCompleteHooks: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>
|
||||
emitStreamEndHooks: (context: ChatStreamEventContext) => Promise<void>
|
||||
emitTokenLiteralHooks: (literal: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitTokenSpecialHooks: (special: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitStreamEndHooks: (context: ChatStreamEventContext) => Promise<void>
|
||||
emitAssistantResponseEndHooks: (message: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitAssistantMessageHooks: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>
|
||||
emitChatTurnCompleteHooks: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>
|
||||
clearHooks: () => void
|
||||
onAfterMessageComposed: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAfterSend: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAssistantMessage: (cb: (message: StreamingAssistantMessage, messageText: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onAssistantResponseEnd: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onBeforeMessageComposed: (cb: (message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>) => () => void
|
||||
onBeforeSend: (cb: (message: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onChatTurnComplete: (cb: (chat: { output: StreamingAssistantMessage, outputText: string, toolCalls: ToolMessage[] }, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onStreamEnd: (cb: (context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onTokenLiteral: (cb: (literal: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
onTokenSpecial: (cb: (special: string, context: ChatStreamEventContext) => Promise<void>) => () => void
|
||||
}
|
||||
|
||||
export interface HookUnsubscribe {
|
||||
(): void
|
||||
}
|
||||
|
||||
export interface AgentHookRegistry<TContext, TAssistantMessage, TToolCall> {
|
||||
onBeforeMessageComposed: (cb: (message: string, context: Omit<TContext, 'composedMessage'>) => Promise<void>) => HookUnsubscribe
|
||||
onAfterMessageComposed: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onBeforeSend: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onAfterSend: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onTokenLiteral: (cb: (literal: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onTokenSpecial: (cb: (special: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onStreamEnd: (cb: (context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onAssistantResponseEnd: (cb: (message: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onAssistantMessage: (cb: (message: TAssistantMessage, messageText: string, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
onChatTurnComplete: (cb: (chat: { output: TAssistantMessage, outputText: string, toolCalls: TToolCall[] }, context: TContext) => Promise<void>) => HookUnsubscribe
|
||||
|
||||
emitBeforeMessageComposedHooks: (message: string, context: Omit<TContext, 'composedMessage'>) => Promise<void>
|
||||
emitAfterMessageComposedHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitBeforeSendHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitAfterSendHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitTokenLiteralHooks: (literal: string, context: TContext) => Promise<void>
|
||||
emitTokenSpecialHooks: (special: string, context: TContext) => Promise<void>
|
||||
emitStreamEndHooks: (context: TContext) => Promise<void>
|
||||
emitAssistantResponseEndHooks: (message: string, context: TContext) => Promise<void>
|
||||
emitAssistantMessageHooks: (message: TAssistantMessage, messageText: string, context: TContext) => Promise<void>
|
||||
emitChatTurnCompleteHooks: (chat: { output: TAssistantMessage, outputText: string, toolCalls: TToolCall[] }, context: TContext) => Promise<void>
|
||||
clearHooks: () => void
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ChatHistoryItem } from '../types/chat'
|
||||
|
||||
export interface AgentSessionPort {
|
||||
ensureSession: (sessionId: string) => void
|
||||
getSessionMessages: (sessionId: string) => ChatHistoryItem[]
|
||||
appendSessionMessage: (sessionId: string, message: ChatHistoryItem) => void
|
||||
ensureSession: (sessionId: string) => void
|
||||
getSessionGeneration: (sessionId: string) => number
|
||||
getSessionMessages: (sessionId: string) => ChatHistoryItem[]
|
||||
}
|
||||
|
||||
@@ -9,55 +9,55 @@ describe('compactConversationEntries', () => {
|
||||
const result = compactConversationEntries({
|
||||
entries: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'weather?',
|
||||
role: 'user',
|
||||
} satisfies RawMessage,
|
||||
{
|
||||
id: 'history-1',
|
||||
role: 'event',
|
||||
segments: [
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: false,
|
||||
items: [
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 1,
|
||||
actor: 'player',
|
||||
action: {
|
||||
kind: 'move-played',
|
||||
san: 'e4',
|
||||
},
|
||||
actor: 'player',
|
||||
turnIndex: 1,
|
||||
turnType: 'chess',
|
||||
type: 'turn',
|
||||
},
|
||||
{
|
||||
type: 'reaction',
|
||||
reactionType: 'spark-command',
|
||||
text: 'Hmm.',
|
||||
type: 'reaction',
|
||||
},
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 2,
|
||||
actor: 'assistant',
|
||||
action: {
|
||||
kind: 'move-executed',
|
||||
san: 'e5',
|
||||
},
|
||||
actor: 'assistant',
|
||||
turnIndex: 2,
|
||||
turnType: 'chess',
|
||||
type: 'turn',
|
||||
},
|
||||
{
|
||||
type: 'reaction',
|
||||
reactionType: 'spark-command',
|
||||
text: 'Let us answer.',
|
||||
type: 'reaction',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
type: 'domain-event',
|
||||
},
|
||||
],
|
||||
type: 'history-block',
|
||||
},
|
||||
],
|
||||
} satisfies Message,
|
||||
|
||||
@@ -10,78 +10,12 @@ export interface CompactConversationEntriesOptions {
|
||||
recentTurnLimit: number
|
||||
/** Optional domain-aware summary formatter used for removed history windows. */
|
||||
summarizeCompactedHistory?: (input: {
|
||||
removedTurnCount: number
|
||||
originalItems: HistoryItem[]
|
||||
keptItems: HistoryItem[]
|
||||
originalItems: HistoryItem[]
|
||||
removedTurnCount: number
|
||||
}) => string
|
||||
}
|
||||
|
||||
function isStructuredMessage(entry: Message | RawMessage): entry is Message {
|
||||
return 'segments' in entry
|
||||
}
|
||||
|
||||
function countTurns(items: HistoryItem[]) {
|
||||
return items.reduce((count, item) => count + (item.type === 'turn' ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function keepRecentHistoryItems(items: HistoryItem[], recentTurnLimit: number) {
|
||||
const keptItems: HistoryItem[] = []
|
||||
let turnCount = 0
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
keptItems.unshift(items[index])
|
||||
if (items[index].type === 'turn')
|
||||
turnCount += 1
|
||||
|
||||
if (turnCount >= recentTurnLimit)
|
||||
break
|
||||
}
|
||||
|
||||
return keptItems
|
||||
}
|
||||
|
||||
function compactHistoryBlock(
|
||||
segment: MessageHistoryBlockSegment,
|
||||
recentTurnLimit: number,
|
||||
summarizeCompactedHistory?: (input: {
|
||||
removedTurnCount: number
|
||||
originalItems: HistoryItem[]
|
||||
keptItems: HistoryItem[]
|
||||
}) => string,
|
||||
): MessageHistoryBlockSegment {
|
||||
if (segment.compacted)
|
||||
return segment
|
||||
|
||||
const historyTurnCount = countTurns(segment.items)
|
||||
if (historyTurnCount <= recentTurnLimit)
|
||||
return segment
|
||||
|
||||
const keptItems = keepRecentHistoryItems(segment.items, recentTurnLimit)
|
||||
const removedTurnCount = historyTurnCount - recentTurnLimit
|
||||
|
||||
return {
|
||||
type: 'history-block',
|
||||
compacted: true,
|
||||
items: [
|
||||
{
|
||||
type: 'summary',
|
||||
text: summarizeCompactedHistory?.({
|
||||
removedTurnCount,
|
||||
originalItems: segment.items,
|
||||
keptItems,
|
||||
}) ?? `Compacted ${removedTurnCount} older turns with paired reactions.`,
|
||||
fromTurnIndex: segment.items.find(item => item.type === 'turn')?.type === 'turn'
|
||||
? (segment.items.find(item => item.type === 'turn')?.turnIndex ?? undefined)
|
||||
: undefined,
|
||||
toTurnIndex: keptItems.findLast(item => item.type === 'turn')?.type === 'turn'
|
||||
? keptItems.findLast(item => item.type === 'turn')?.turnIndex
|
||||
: undefined,
|
||||
},
|
||||
...keptItems,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compacts older structured history blocks while preserving recent turn/reaction pairs.
|
||||
*
|
||||
@@ -116,3 +50,69 @@ export function compactConversationEntries(input: CompactConversationEntriesOpti
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function compactHistoryBlock(
|
||||
segment: MessageHistoryBlockSegment,
|
||||
recentTurnLimit: number,
|
||||
summarizeCompactedHistory?: (input: {
|
||||
keptItems: HistoryItem[]
|
||||
originalItems: HistoryItem[]
|
||||
removedTurnCount: number
|
||||
}) => string,
|
||||
): MessageHistoryBlockSegment {
|
||||
if (segment.compacted)
|
||||
return segment
|
||||
|
||||
const historyTurnCount = countTurns(segment.items)
|
||||
if (historyTurnCount <= recentTurnLimit)
|
||||
return segment
|
||||
|
||||
const keptItems = keepRecentHistoryItems(segment.items, recentTurnLimit)
|
||||
const removedTurnCount = historyTurnCount - recentTurnLimit
|
||||
|
||||
return {
|
||||
compacted: true,
|
||||
items: [
|
||||
{
|
||||
fromTurnIndex: segment.items.find(item => item.type === 'turn')?.type === 'turn'
|
||||
? (segment.items.find(item => item.type === 'turn')?.turnIndex ?? undefined)
|
||||
: undefined,
|
||||
text: summarizeCompactedHistory?.({
|
||||
keptItems,
|
||||
originalItems: segment.items,
|
||||
removedTurnCount,
|
||||
}) ?? `Compacted ${removedTurnCount} older turns with paired reactions.`,
|
||||
toTurnIndex: keptItems.findLast(item => item.type === 'turn')?.type === 'turn'
|
||||
? keptItems.findLast(item => item.type === 'turn')?.turnIndex
|
||||
: undefined,
|
||||
type: 'summary',
|
||||
},
|
||||
...keptItems,
|
||||
],
|
||||
type: 'history-block',
|
||||
}
|
||||
}
|
||||
|
||||
function countTurns(items: HistoryItem[]) {
|
||||
return items.reduce((count, item) => count + (item.type === 'turn' ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function isStructuredMessage(entry: Message | RawMessage): entry is Message {
|
||||
return 'segments' in entry
|
||||
}
|
||||
|
||||
function keepRecentHistoryItems(items: HistoryItem[], recentTurnLimit: number) {
|
||||
const keptItems: HistoryItem[] = []
|
||||
let turnCount = 0
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
keptItems.unshift(items[index])
|
||||
if (items[index].type === 'turn')
|
||||
turnCount += 1
|
||||
|
||||
if (turnCount >= recentTurnLimit)
|
||||
break
|
||||
}
|
||||
|
||||
return keptItems
|
||||
}
|
||||
|
||||
@@ -9,11 +9,9 @@ function makeContext(overrides: Record<string, unknown> = {}): ContextSnapshot {
|
||||
return {
|
||||
'system:minecraft-integration': [
|
||||
{
|
||||
id: 'volatile-random-id',
|
||||
contextId: 'system:minecraft-integration',
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'Bot is online in forest biome',
|
||||
createdAt: 1743940440000,
|
||||
id: 'volatile-random-id',
|
||||
metadata: {
|
||||
source: {
|
||||
id: 'system:minecraft-integration',
|
||||
@@ -21,6 +19,8 @@ function makeContext(overrides: Record<string, unknown> = {}): ContextSnapshot {
|
||||
plugin: { id: 'airi:minecraft' },
|
||||
},
|
||||
},
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'Bot is online in forest biome',
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
@@ -73,20 +73,20 @@ describe('formatContextPromptText', () => {
|
||||
const snapshot: ContextSnapshot = {
|
||||
'system:minecraft-integration': [
|
||||
{
|
||||
id: 'a',
|
||||
contextId: 'system:minecraft-integration',
|
||||
createdAt: 0,
|
||||
id: 'a',
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'Bot is online',
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
'system:weather': [
|
||||
{
|
||||
id: 'b',
|
||||
contextId: 'system:weather',
|
||||
createdAt: 0,
|
||||
id: 'b',
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'Sunny, 22C',
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -10,6 +10,35 @@ import type { ContextMessage } from '../types/chat'
|
||||
*/
|
||||
export type ContextSnapshot = Record<string, ContextMessage[]>
|
||||
|
||||
/**
|
||||
* Builds a user-role context prompt message from active runtime context.
|
||||
*
|
||||
* Use when:
|
||||
* - A caller needs the historical standalone context prompt shape.
|
||||
*
|
||||
* Expects:
|
||||
* - Context messages have already been bucketed and cloned by the context registry.
|
||||
*
|
||||
* Returns:
|
||||
* - `null` when no prompt text is available.
|
||||
* - A user message carrying the rendered context text otherwise.
|
||||
*/
|
||||
export function buildContextPromptMessage(contextsSnapshot: ContextSnapshot): null | UserMessage {
|
||||
const promptText = formatContextPromptText(contextsSnapshot)
|
||||
if (!promptText)
|
||||
return null
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
text: promptText,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
role: 'user',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render runtime context modules into a compact, readable text block.
|
||||
*
|
||||
@@ -48,32 +77,3 @@ export function formatContextPromptText(contextsSnapshot: ContextSnapshot) {
|
||||
|
||||
return ['[Context]', ...lines].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a user-role context prompt message from active runtime context.
|
||||
*
|
||||
* Use when:
|
||||
* - A caller needs the historical standalone context prompt shape.
|
||||
*
|
||||
* Expects:
|
||||
* - Context messages have already been bucketed and cloned by the context registry.
|
||||
*
|
||||
* Returns:
|
||||
* - `null` when no prompt text is available.
|
||||
* - A user message carrying the rendered context text otherwise.
|
||||
*/
|
||||
export function buildContextPromptMessage(contextsSnapshot: ContextSnapshot): UserMessage | null {
|
||||
const promptText = formatContextPromptText(contextsSnapshot)
|
||||
if (!promptText)
|
||||
return null
|
||||
|
||||
return {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: promptText,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
function padDatePart(value: number): string {
|
||||
return value.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp as `[YYYY-MM-DD HH:MM] ` in the user's local timezone.
|
||||
*
|
||||
@@ -32,3 +28,7 @@ export function formatTimePrefix(createdAt: number): string {
|
||||
|
||||
return `[${year}-${month}-${day} ${hour}:${minute}] `
|
||||
}
|
||||
|
||||
function padDatePart(value: number): string {
|
||||
return value.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import { projectConversationEntries, projectProjection } from './projection'
|
||||
describe('projectProjection', () => {
|
||||
it('projects a domain event into a structured event message', () => {
|
||||
const result = projectProjection({
|
||||
type: 'domain-event',
|
||||
id: 'event-1',
|
||||
domain: 'chess',
|
||||
id: 'event-1',
|
||||
name: 'move-resolved',
|
||||
payload: {
|
||||
moveSan: 'e4',
|
||||
},
|
||||
type: 'domain-event',
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
@@ -28,8 +28,8 @@ describe('projectConversationEntries', () => {
|
||||
it('keeps existing entries before projected entries', () => {
|
||||
const entries: Array<Message | RawMessage> = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system',
|
||||
role: 'system',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -37,9 +37,9 @@ describe('projectConversationEntries', () => {
|
||||
entries,
|
||||
projections: [
|
||||
{
|
||||
type: 'session-user-turn',
|
||||
id: 'turn-1',
|
||||
content: 'hello',
|
||||
id: 'turn-1',
|
||||
type: 'session-user-turn',
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -54,25 +54,25 @@ describe('projectConversationEntries', () => {
|
||||
entries: [],
|
||||
projections: [
|
||||
{
|
||||
type: 'spark-notify',
|
||||
id: 'notify-1',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
destinations: ['character'],
|
||||
headline: 'chess update',
|
||||
id: 'notify-1',
|
||||
note: 'Project a board update',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
destinations: ['character'],
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
type: 'spark-notify',
|
||||
},
|
||||
{
|
||||
type: 'spark-command',
|
||||
id: 'command-1',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
commandId: 'command-1',
|
||||
parentEventId: 'notify-1',
|
||||
intent: 'action',
|
||||
ack: 'play e5',
|
||||
commandId: 'command-1',
|
||||
destinations: ['character'],
|
||||
id: 'command-1',
|
||||
intent: 'action',
|
||||
parentEventId: 'notify-1',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
type: 'spark-command',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,227 +1,77 @@
|
||||
import type { HistoryItem, Message, RawMessage } from './types'
|
||||
|
||||
/**
|
||||
* Projection payload for one user-authored session turn.
|
||||
* Union of projection payloads accepted by the generic message projection pipeline.
|
||||
*/
|
||||
export interface ProjectionSessionUserTurn {
|
||||
type: 'session-user-turn'
|
||||
id: string
|
||||
content: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
export type Projection
|
||||
= ProjectionCompactedHistory
|
||||
| ProjectionDomainEvent
|
||||
| ProjectionSessionUserTurn
|
||||
| ProjectionSparkCommand
|
||||
| ProjectionSparkNotify
|
||||
|
||||
/**
|
||||
* Projection payload for one `spark:notify` event.
|
||||
* Projection payload for one already-compacted history block.
|
||||
*/
|
||||
export interface ProjectionSparkNotify {
|
||||
type: 'spark-notify'
|
||||
export interface ProjectionCompactedHistory {
|
||||
id: string
|
||||
source: string
|
||||
headline: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
destinations: string[]
|
||||
items: HistoryItem[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection payload for one `spark:command` event.
|
||||
*/
|
||||
export interface ProjectionSparkCommand {
|
||||
type: 'spark-command'
|
||||
id: string
|
||||
source?: string
|
||||
commandId: string
|
||||
parentEventId?: string
|
||||
intent?: string
|
||||
ack?: string
|
||||
destinations: string[]
|
||||
guidance?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
summary?: string
|
||||
type: 'compacted-history'
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection payload for one structured domain event.
|
||||
*/
|
||||
export interface ProjectionDomainEvent {
|
||||
type: 'domain-event'
|
||||
id: string
|
||||
domain: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
name?: string
|
||||
payload: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
type: 'domain-event'
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection payload for one already-compacted history block.
|
||||
* Projection payload for one user-authored session turn.
|
||||
*/
|
||||
export interface ProjectionCompactedHistory {
|
||||
type: 'compacted-history'
|
||||
export interface ProjectionSessionUserTurn {
|
||||
content: string
|
||||
id: string
|
||||
source?: string
|
||||
summary?: string
|
||||
items: HistoryItem[]
|
||||
metadata?: Record<string, unknown>
|
||||
type: 'session-user-turn'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of projection payloads accepted by the generic message projection pipeline.
|
||||
* Projection payload for one `spark:command` event.
|
||||
*/
|
||||
export type Projection
|
||||
= ProjectionSessionUserTurn
|
||||
| ProjectionSparkNotify
|
||||
| ProjectionSparkCommand
|
||||
| ProjectionDomainEvent
|
||||
| ProjectionCompactedHistory
|
||||
|
||||
function toInstructionSegment(text: string, priority?: 'low' | 'normal' | 'high' | 'critical') {
|
||||
return {
|
||||
type: 'instruction',
|
||||
text,
|
||||
priority,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toTaggedTextSegment(tag: string, text: string) {
|
||||
return {
|
||||
type: 'tagged-text',
|
||||
tag,
|
||||
text,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toDomainEventSegment(eventType: string, payload: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'domain-event',
|
||||
eventType,
|
||||
payload,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toStateSnapshotSegment(stateType: string, payload: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'state-snapshot',
|
||||
stateType,
|
||||
payload,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toSummarySegment(text: string, metadata?: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'summary',
|
||||
text,
|
||||
metadata,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toReferenceSegment(refType: string, targetId: string, note?: string) {
|
||||
return {
|
||||
type: 'reference',
|
||||
refType,
|
||||
targetId,
|
||||
note,
|
||||
} as const
|
||||
export interface ProjectionSparkCommand {
|
||||
ack?: string
|
||||
commandId: string
|
||||
destinations: string[]
|
||||
guidance?: Record<string, unknown>
|
||||
id: string
|
||||
intent?: string
|
||||
metadata?: Record<string, unknown>
|
||||
parentEventId?: string
|
||||
source?: string
|
||||
type: 'spark-command'
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a projection into one or more conversation entries.
|
||||
*
|
||||
* Use when:
|
||||
* - You need to append structured projection output to a conversation stream
|
||||
* - You want a single projection pipeline to handle session, spark, and domain inputs
|
||||
*
|
||||
* Expects:
|
||||
* - Projection payloads to already be normalized
|
||||
*
|
||||
* Returns:
|
||||
* - Provider-ready raw messages or structured messages in stable order
|
||||
* Projection payload for one `spark:notify` event.
|
||||
*/
|
||||
export function projectProjection(projection: Projection): Array<Message | RawMessage> {
|
||||
if (projection.type === 'session-user-turn') {
|
||||
return [{
|
||||
role: 'user',
|
||||
content: projection.content,
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'compacted-history') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.source,
|
||||
segments: [
|
||||
toSummarySegment(projection.summary ?? 'Compacted history block.'),
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: true,
|
||||
items: projection.items,
|
||||
},
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'domain-event') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.domain,
|
||||
segments: [
|
||||
toDomainEventSegment(projection.name ?? projection.domain, projection.payload),
|
||||
toReferenceSegment('domain', projection.domain, projection.name),
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'spark-notify') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.source,
|
||||
segments: [
|
||||
toInstructionSegment(`Handle spark notify from ${projection.source}.`),
|
||||
toTaggedTextSegment(
|
||||
'spark-notify',
|
||||
[
|
||||
`Headline: ${projection.headline}.`,
|
||||
projection.note ? `Note: ${projection.note}` : undefined,
|
||||
projection.payload ? `Payload: ${JSON.stringify(projection.payload, null, 2)}` : undefined,
|
||||
projection.destinations.length > 0 ? `Destinations: ${projection.destinations.join(', ')}.` : undefined,
|
||||
].filter(Boolean).join('\n'),
|
||||
),
|
||||
toReferenceSegment('source', projection.source),
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.source,
|
||||
segments: [
|
||||
toInstructionSegment(`Execute spark command ${projection.commandId}.`, 'high'),
|
||||
toTaggedTextSegment(
|
||||
'spark-command',
|
||||
[
|
||||
projection.source ? `Source: ${projection.source}.` : undefined,
|
||||
projection.parentEventId ? `Parent event: ${projection.parentEventId}.` : undefined,
|
||||
projection.intent ? `Intent: ${projection.intent}.` : undefined,
|
||||
projection.ack ? `Ack: ${projection.ack}.` : undefined,
|
||||
projection.guidance ? `Guidance: ${JSON.stringify(projection.guidance, null, 2)}` : undefined,
|
||||
projection.destinations.length > 0 ? `Destinations: ${projection.destinations.join(', ')}.` : undefined,
|
||||
].filter(Boolean).join('\n'),
|
||||
),
|
||||
toStateSnapshotSegment('spark-command', {
|
||||
commandId: projection.commandId,
|
||||
parentEventId: projection.parentEventId,
|
||||
destinations: projection.destinations,
|
||||
}),
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
export interface ProjectionSparkNotify {
|
||||
destinations: string[]
|
||||
headline: string
|
||||
id: string
|
||||
metadata?: Record<string, unknown>
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
source: string
|
||||
type: 'spark-notify'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,3 +96,153 @@ export function projectConversationEntries(input: {
|
||||
...input.projections.flatMap(projectProjection),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a projection into one or more conversation entries.
|
||||
*
|
||||
* Use when:
|
||||
* - You need to append structured projection output to a conversation stream
|
||||
* - You want a single projection pipeline to handle session, spark, and domain inputs
|
||||
*
|
||||
* Expects:
|
||||
* - Projection payloads to already be normalized
|
||||
*
|
||||
* Returns:
|
||||
* - Provider-ready raw messages or structured messages in stable order
|
||||
*/
|
||||
export function projectProjection(projection: Projection): Array<Message | RawMessage> {
|
||||
if (projection.type === 'session-user-turn') {
|
||||
return [{
|
||||
content: projection.content,
|
||||
metadata: projection.metadata,
|
||||
role: 'user',
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'compacted-history') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
metadata: projection.metadata,
|
||||
role: 'event',
|
||||
segments: [
|
||||
toSummarySegment(projection.summary ?? 'Compacted history block.'),
|
||||
{
|
||||
compacted: true,
|
||||
items: projection.items,
|
||||
type: 'history-block',
|
||||
},
|
||||
],
|
||||
source: projection.source,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'domain-event') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
metadata: projection.metadata,
|
||||
role: 'event',
|
||||
segments: [
|
||||
toDomainEventSegment(projection.name ?? projection.domain, projection.payload),
|
||||
toReferenceSegment('domain', projection.domain, projection.name),
|
||||
],
|
||||
source: projection.domain,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'spark-notify') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
metadata: projection.metadata,
|
||||
role: 'event',
|
||||
segments: [
|
||||
toInstructionSegment(`Handle spark notify from ${projection.source}.`),
|
||||
toTaggedTextSegment(
|
||||
'spark-notify',
|
||||
[
|
||||
`Headline: ${projection.headline}.`,
|
||||
projection.note ? `Note: ${projection.note}` : undefined,
|
||||
projection.payload ? `Payload: ${JSON.stringify(projection.payload, null, 2)}` : undefined,
|
||||
projection.destinations.length > 0 ? `Destinations: ${projection.destinations.join(', ')}.` : undefined,
|
||||
].filter(Boolean).join('\n'),
|
||||
),
|
||||
toReferenceSegment('source', projection.source),
|
||||
],
|
||||
source: projection.source,
|
||||
}]
|
||||
}
|
||||
|
||||
return [{
|
||||
id: projection.id,
|
||||
metadata: projection.metadata,
|
||||
role: 'event',
|
||||
segments: [
|
||||
toInstructionSegment(`Execute spark command ${projection.commandId}.`, 'high'),
|
||||
toTaggedTextSegment(
|
||||
'spark-command',
|
||||
[
|
||||
projection.source ? `Source: ${projection.source}.` : undefined,
|
||||
projection.parentEventId ? `Parent event: ${projection.parentEventId}.` : undefined,
|
||||
projection.intent ? `Intent: ${projection.intent}.` : undefined,
|
||||
projection.ack ? `Ack: ${projection.ack}.` : undefined,
|
||||
projection.guidance ? `Guidance: ${JSON.stringify(projection.guidance, null, 2)}` : undefined,
|
||||
projection.destinations.length > 0 ? `Destinations: ${projection.destinations.join(', ')}.` : undefined,
|
||||
].filter(Boolean).join('\n'),
|
||||
),
|
||||
toStateSnapshotSegment('spark-command', {
|
||||
commandId: projection.commandId,
|
||||
destinations: projection.destinations,
|
||||
parentEventId: projection.parentEventId,
|
||||
}),
|
||||
],
|
||||
source: projection.source,
|
||||
}]
|
||||
}
|
||||
|
||||
function toDomainEventSegment(eventType: string, payload: Record<string, unknown>) {
|
||||
return {
|
||||
eventType,
|
||||
payload,
|
||||
type: 'domain-event',
|
||||
} as const
|
||||
}
|
||||
|
||||
function toInstructionSegment(text: string, priority?: 'critical' | 'high' | 'low' | 'normal') {
|
||||
return {
|
||||
priority,
|
||||
text,
|
||||
type: 'instruction',
|
||||
} as const
|
||||
}
|
||||
|
||||
function toReferenceSegment(refType: string, targetId: string, note?: string) {
|
||||
return {
|
||||
note,
|
||||
refType,
|
||||
targetId,
|
||||
type: 'reference',
|
||||
} as const
|
||||
}
|
||||
|
||||
function toStateSnapshotSegment(stateType: string, payload: Record<string, unknown>) {
|
||||
return {
|
||||
payload,
|
||||
stateType,
|
||||
type: 'state-snapshot',
|
||||
} as const
|
||||
}
|
||||
|
||||
function toSummarySegment(text: string, metadata?: Record<string, unknown>) {
|
||||
return {
|
||||
metadata,
|
||||
text,
|
||||
type: 'summary',
|
||||
} as const
|
||||
}
|
||||
|
||||
function toTaggedTextSegment(tag: string, text: string) {
|
||||
return {
|
||||
tag,
|
||||
text,
|
||||
type: 'tagged-text',
|
||||
} as const
|
||||
}
|
||||
|
||||
@@ -8,85 +8,85 @@ describe('renderProviderChatMessages', () => {
|
||||
it('renders structured event messages into raw provider chat messages', () => {
|
||||
const entries: Array<Message | RawMessage> = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system prompt',
|
||||
role: 'system',
|
||||
},
|
||||
{
|
||||
id: 'event-1',
|
||||
role: 'event',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
segments: [
|
||||
{
|
||||
type: 'instruction',
|
||||
text: 'Keep the reply short.',
|
||||
priority: 'critical',
|
||||
text: 'Keep the reply short.',
|
||||
type: 'instruction',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Chess update',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'tagged-text',
|
||||
tag: 'agent_spark_command_reaction',
|
||||
text: 'Move accepted.',
|
||||
type: 'tagged-text',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
type: 'domain-event',
|
||||
},
|
||||
{
|
||||
type: 'state-snapshot',
|
||||
stateType: 'board',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
stateType: 'board',
|
||||
type: 'state-snapshot',
|
||||
},
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: true,
|
||||
items: [
|
||||
{
|
||||
type: 'summary',
|
||||
text: 'Compacted history.',
|
||||
fromTurnIndex: 1,
|
||||
text: 'Compacted history.',
|
||||
toTurnIndex: 3,
|
||||
type: 'summary',
|
||||
},
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 3,
|
||||
actor: 'assistant',
|
||||
action: {
|
||||
kind: 'move-executed',
|
||||
san: 'e5',
|
||||
},
|
||||
actor: 'assistant',
|
||||
turnIndex: 3,
|
||||
turnType: 'chess',
|
||||
type: 'turn',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
type: 'domain-event',
|
||||
},
|
||||
],
|
||||
type: 'history-block',
|
||||
},
|
||||
{
|
||||
type: 'summary',
|
||||
text: 'Earlier turns compacted.',
|
||||
metadata: {
|
||||
span: 2,
|
||||
},
|
||||
text: 'Earlier turns compacted.',
|
||||
type: 'summary',
|
||||
},
|
||||
{
|
||||
type: 'reference',
|
||||
note: 'Recent move',
|
||||
refType: 'turn',
|
||||
targetId: 'turn-2',
|
||||
note: 'Recent move',
|
||||
type: 'reference',
|
||||
},
|
||||
],
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
import type { HistoryItem, Message, MessageSegment, RawMessage } from './types'
|
||||
|
||||
/**
|
||||
* Renders structured messages into provider chat messages with stable ordering.
|
||||
*
|
||||
* Use when:
|
||||
* - Preparing a chat completion input array
|
||||
* - Projected messages must be flattened into raw provider chat text without leaking domain-specific renderer logic
|
||||
*
|
||||
* Expects:
|
||||
* - Structured messages to contain renderable segments
|
||||
* - `mode` to describe the prompt surface, even when rendering stays identical
|
||||
*
|
||||
* Returns:
|
||||
* - Raw provider chat messages in the same order as the input entries
|
||||
*/
|
||||
export function renderProviderChatMessages(input: {
|
||||
entries: Array<Message | RawMessage>
|
||||
mode: 'eval-debug' | 'session-main' | 'session-spark-command' | 'session-spark-notify'
|
||||
}): RawMessage[] {
|
||||
const attachSourceName = input.mode !== 'session-main'
|
||||
|
||||
return input.entries.map((entry) => {
|
||||
if ('content' in entry)
|
||||
return entry
|
||||
|
||||
return {
|
||||
content: entry.segments.map(renderSegmentText).join('\n'),
|
||||
metadata: entry.metadata,
|
||||
name: attachSourceName ? entry.source : undefined,
|
||||
role: mapStructuredRole(entry.role),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mapStructuredRole(role: Message['role']): RawMessage['role'] {
|
||||
if (role === 'context' || role === 'event' || role === 'summary')
|
||||
return 'system'
|
||||
|
||||
return role
|
||||
}
|
||||
|
||||
function renderHistoryAction(item: HistoryItem) {
|
||||
if (item.type === 'summary') {
|
||||
return [
|
||||
@@ -78,43 +118,3 @@ function renderSegmentText(segment: MessageSegment): string {
|
||||
|
||||
return segment.items.map(renderHistoryAction).join('\n')
|
||||
}
|
||||
|
||||
function mapStructuredRole(role: Message['role']): RawMessage['role'] {
|
||||
if (role === 'context' || role === 'event' || role === 'summary')
|
||||
return 'system'
|
||||
|
||||
return role
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders structured messages into provider chat messages with stable ordering.
|
||||
*
|
||||
* Use when:
|
||||
* - Preparing a chat completion input array
|
||||
* - Projected messages must be flattened into raw provider chat text without leaking domain-specific renderer logic
|
||||
*
|
||||
* Expects:
|
||||
* - Structured messages to contain renderable segments
|
||||
* - `mode` to describe the prompt surface, even when rendering stays identical
|
||||
*
|
||||
* Returns:
|
||||
* - Raw provider chat messages in the same order as the input entries
|
||||
*/
|
||||
export function renderProviderChatMessages(input: {
|
||||
entries: Array<Message | RawMessage>
|
||||
mode: 'session-main' | 'session-spark-notify' | 'session-spark-command' | 'eval-debug'
|
||||
}): RawMessage[] {
|
||||
const attachSourceName = input.mode !== 'session-main'
|
||||
|
||||
return input.entries.map((entry) => {
|
||||
if ('content' in entry)
|
||||
return entry
|
||||
|
||||
return {
|
||||
role: mapStructuredRole(entry.role),
|
||||
content: entry.segments.map(renderSegmentText).join('\n'),
|
||||
name: attachSourceName ? entry.source : undefined,
|
||||
metadata: entry.metadata,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,96 +18,96 @@ describe('message types', () => {
|
||||
it('supports structured history blocks and provider-ready raw messages', () => {
|
||||
const history: HistoryItem[] = [
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 1,
|
||||
actor: 'player',
|
||||
action: {
|
||||
kind: 'move-played',
|
||||
san: 'e4',
|
||||
},
|
||||
actor: 'player',
|
||||
turnIndex: 1,
|
||||
turnType: 'chess',
|
||||
type: 'turn',
|
||||
},
|
||||
{
|
||||
type: 'reaction',
|
||||
reactionType: 'spark-command',
|
||||
text: 'Good move.',
|
||||
type: 'reaction',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
type: 'domain-event',
|
||||
},
|
||||
]
|
||||
|
||||
const segments: Array<
|
||||
SegmentText
|
||||
| SegmentInstruction
|
||||
| SegmentTaggedText
|
||||
| SegmentDomainEvent
|
||||
| SegmentStateSnapshot
|
||||
SegmentDomainEvent
|
||||
| SegmentHistoryBlock
|
||||
| SegmentSummary
|
||||
| SegmentInstruction
|
||||
| SegmentReference
|
||||
| SegmentStateSnapshot
|
||||
| SegmentSummary
|
||||
| SegmentTaggedText
|
||||
| SegmentText
|
||||
> = [
|
||||
{
|
||||
type: 'instruction',
|
||||
text: 'Explain the current board state.',
|
||||
priority: 'high',
|
||||
text: 'Explain the current board state.',
|
||||
type: 'instruction',
|
||||
},
|
||||
{
|
||||
type: 'tagged-text',
|
||||
tag: 'agent_spark_command_reaction',
|
||||
text: 'Good move.',
|
||||
type: 'tagged-text',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
type: 'domain-event',
|
||||
},
|
||||
{
|
||||
type: 'state-snapshot',
|
||||
stateType: 'board',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
stateType: 'board',
|
||||
type: 'state-snapshot',
|
||||
},
|
||||
{
|
||||
type: 'summary',
|
||||
text: 'Older chess turns compacted.',
|
||||
type: 'summary',
|
||||
},
|
||||
{
|
||||
type: 'reference',
|
||||
note: 'Latest paired move',
|
||||
refType: 'turn',
|
||||
targetId: 'turn-1',
|
||||
note: 'Latest paired move',
|
||||
type: 'reference',
|
||||
},
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: false,
|
||||
items: history,
|
||||
type: 'history-block',
|
||||
},
|
||||
]
|
||||
|
||||
const structuredMessage: Message = {
|
||||
id: 'msg-1',
|
||||
role: 'event',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
segments,
|
||||
metadata: {
|
||||
domain: 'chess',
|
||||
},
|
||||
role: 'event',
|
||||
segments,
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
}
|
||||
|
||||
const rawMessage: RawMessage = {
|
||||
role: 'user',
|
||||
content: 'continue',
|
||||
metadata: {
|
||||
source: 'session',
|
||||
},
|
||||
role: 'user',
|
||||
}
|
||||
|
||||
const historyBlock = structuredMessage.segments[6] as SegmentHistoryBlock
|
||||
|
||||
@@ -1,21 +1,102 @@
|
||||
/**
|
||||
* Provider-ready message payload.
|
||||
*
|
||||
* Use when:
|
||||
* - Sending messages to chat-style providers
|
||||
* - Preserving a simple role/content shape alongside richer projected messages
|
||||
*
|
||||
* Expects:
|
||||
* - `content` already serialized into a provider-safe string
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal chat message record that providers can consume directly
|
||||
* Structured item stored inside a history block.
|
||||
*/
|
||||
export interface RawMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content: string
|
||||
name?: string
|
||||
export type HistoryItem
|
||||
= HistoryItemDomainEvent
|
||||
| HistoryReaction
|
||||
| HistorySummary
|
||||
| HistoryTurn
|
||||
|
||||
/**
|
||||
* History domain event item used to preserve structured event provenance.
|
||||
*/
|
||||
export interface HistoryItemDomainEvent {
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
type: 'domain-event'
|
||||
}
|
||||
|
||||
/**
|
||||
* History reaction item used to keep spark output close to the related turn.
|
||||
*/
|
||||
export interface HistoryReaction {
|
||||
reactionType: 'spark-command' | 'spark-notify' | string
|
||||
source?: string
|
||||
text: string
|
||||
type: 'reaction'
|
||||
}
|
||||
|
||||
/**
|
||||
* History summary item used by a history block segment.
|
||||
*/
|
||||
export interface HistorySummary {
|
||||
fromTurnIndex?: number
|
||||
metadata?: Record<string, unknown>
|
||||
text: string
|
||||
toTurnIndex?: number
|
||||
type: 'summary'
|
||||
}
|
||||
|
||||
/**
|
||||
* History turn item used for structured session or domain turn tracking.
|
||||
*/
|
||||
export interface HistoryTurn {
|
||||
action: HistoryTurnAction
|
||||
actor: 'agent' | 'assistant' | 'player' | 'system' | string
|
||||
turnIndex: number
|
||||
turnType: string
|
||||
type: 'turn'
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured action stored on a turn history item.
|
||||
*/
|
||||
export type HistoryTurnAction
|
||||
= HistoryTurnEventAction
|
||||
| HistoryTurnGenericAction
|
||||
| HistoryTurnMoveAction
|
||||
| HistoryTurnTextAction
|
||||
|
||||
/**
|
||||
* Event action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnEventAction {
|
||||
kind: 'event'
|
||||
name: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fallback action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnGenericAction {
|
||||
[key: string]: unknown
|
||||
fen?: string
|
||||
kind: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
san?: string
|
||||
uci?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Chess-style move action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnMoveAction {
|
||||
fen?: string
|
||||
kind: 'move-executed' | 'move-played'
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
san: string
|
||||
uci?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Text action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnTextAction {
|
||||
kind: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,213 +114,26 @@ export interface RawMessage {
|
||||
*/
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'system' | 'user' | 'assistant' | 'context' | 'event' | 'summary'
|
||||
source?: string
|
||||
metadata?: Record<string, unknown>
|
||||
role: 'assistant' | 'context' | 'event' | 'summary' | 'system' | 'user'
|
||||
segments: MessageSegment[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured content segment used inside a projected message.
|
||||
*/
|
||||
export type MessageSegment
|
||||
= SegmentText
|
||||
| SegmentInstruction
|
||||
| SegmentTaggedText
|
||||
| SegmentDomainEvent
|
||||
| SegmentStateSnapshot
|
||||
| SegmentHistoryBlock
|
||||
| SegmentSummary
|
||||
| SegmentReference
|
||||
|
||||
/**
|
||||
* Plain text segment for projected message rendering.
|
||||
*/
|
||||
export interface SegmentText {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruction segment for explicit runtime or system guidance.
|
||||
*/
|
||||
export interface SegmentInstruction {
|
||||
type: 'instruction'
|
||||
text: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'critical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged text segment that preserves semantic tag boundaries.
|
||||
*/
|
||||
export interface SegmentTaggedText {
|
||||
type: 'tagged-text'
|
||||
tag: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain event segment for structured event payloads.
|
||||
*/
|
||||
export interface SegmentDomainEvent {
|
||||
type: 'domain-event'
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* State snapshot segment for deterministic state serialization.
|
||||
*/
|
||||
export interface SegmentStateSnapshot {
|
||||
type: 'state-snapshot'
|
||||
stateType: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* History block segment that keeps turn/reaction pairing intact.
|
||||
*/
|
||||
export interface SegmentHistoryBlock {
|
||||
type: 'history-block'
|
||||
compacted: boolean
|
||||
items: HistoryItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* History summary item used by a history block segment.
|
||||
*/
|
||||
export interface HistorySummary {
|
||||
type: 'summary'
|
||||
text: string
|
||||
fromTurnIndex?: number
|
||||
toTurnIndex?: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* History reaction item used to keep spark output close to the related turn.
|
||||
*/
|
||||
export interface HistoryReaction {
|
||||
type: 'reaction'
|
||||
reactionType: 'spark-notify' | 'spark-command' | string
|
||||
text: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* History turn item used for structured session or domain turn tracking.
|
||||
*/
|
||||
export interface HistoryTurn {
|
||||
type: 'turn'
|
||||
turnType: string
|
||||
turnIndex: number
|
||||
actor: 'player' | 'assistant' | 'agent' | 'system' | string
|
||||
action: HistoryTurnAction
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured action stored on a turn history item.
|
||||
*/
|
||||
export type HistoryTurnAction
|
||||
= HistoryTurnMoveAction
|
||||
| HistoryTurnTextAction
|
||||
| HistoryTurnEventAction
|
||||
| HistoryTurnGenericAction
|
||||
|
||||
/**
|
||||
* Chess-style move action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnMoveAction {
|
||||
kind: 'move-played' | 'move-executed'
|
||||
san: string
|
||||
uci?: string
|
||||
fen?: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Text action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnTextAction {
|
||||
kind: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Event action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnEventAction {
|
||||
kind: 'event'
|
||||
name: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fallback action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnGenericAction {
|
||||
kind: string
|
||||
san?: string
|
||||
uci?: string
|
||||
fen?: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured item stored inside a history block.
|
||||
*/
|
||||
export type HistoryItem
|
||||
= HistorySummary
|
||||
| HistoryReaction
|
||||
| HistoryItemDomainEvent
|
||||
| HistoryTurn
|
||||
|
||||
/**
|
||||
* History domain event item used to preserve structured event provenance.
|
||||
*/
|
||||
export interface HistoryItemDomainEvent {
|
||||
type: 'domain-event'
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for the text segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageTextSegment = SegmentText
|
||||
|
||||
/**
|
||||
* Alias for the instruction segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageInstructionSegment = SegmentInstruction
|
||||
|
||||
/**
|
||||
* Alias for the tagged text segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageTaggedTextSegment = SegmentTaggedText
|
||||
|
||||
/**
|
||||
* Alias for the domain event segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageDomainEventSegment = SegmentDomainEvent
|
||||
|
||||
/**
|
||||
* Alias for the state snapshot segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageStateSnapshotSegment = SegmentStateSnapshot
|
||||
|
||||
/**
|
||||
* Alias for the history block segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageHistoryBlockSegment = SegmentHistoryBlock
|
||||
|
||||
/**
|
||||
* Alias for the summary segment shape used by the approved spec.
|
||||
* Alias for the instruction segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageSummarySegment = SegmentSummary
|
||||
export type MessageInstructionSegment = SegmentInstruction
|
||||
|
||||
/**
|
||||
* Alias for the reference segment shape used by the approved spec.
|
||||
@@ -247,20 +141,126 @@ export type MessageSummarySegment = SegmentSummary
|
||||
export type MessageReferenceSegment = SegmentReference
|
||||
|
||||
/**
|
||||
* Summary segment for historical or narrative windows.
|
||||
* Structured content segment used inside a projected message.
|
||||
*/
|
||||
export interface SegmentSummary {
|
||||
type: 'summary'
|
||||
text: string
|
||||
export type MessageSegment
|
||||
= SegmentDomainEvent
|
||||
| SegmentHistoryBlock
|
||||
| SegmentInstruction
|
||||
| SegmentReference
|
||||
| SegmentStateSnapshot
|
||||
| SegmentSummary
|
||||
| SegmentTaggedText
|
||||
| SegmentText
|
||||
|
||||
/**
|
||||
* Alias for the state snapshot segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageStateSnapshotSegment = SegmentStateSnapshot
|
||||
|
||||
/**
|
||||
* Alias for the summary segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageSummarySegment = SegmentSummary
|
||||
|
||||
/**
|
||||
* Alias for the tagged text segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageTaggedTextSegment = SegmentTaggedText
|
||||
|
||||
/**
|
||||
* Alias for the text segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageTextSegment = SegmentText
|
||||
|
||||
/**
|
||||
* Provider-ready message payload.
|
||||
*
|
||||
* Use when:
|
||||
* - Sending messages to chat-style providers
|
||||
* - Preserving a simple role/content shape alongside richer projected messages
|
||||
*
|
||||
* Expects:
|
||||
* - `content` already serialized into a provider-safe string
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal chat message record that providers can consume directly
|
||||
*/
|
||||
export interface RawMessage {
|
||||
content: string
|
||||
metadata?: Record<string, unknown>
|
||||
name?: string
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain event segment for structured event payloads.
|
||||
*/
|
||||
export interface SegmentDomainEvent {
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
type: 'domain-event'
|
||||
}
|
||||
|
||||
/**
|
||||
* History block segment that keeps turn/reaction pairing intact.
|
||||
*/
|
||||
export interface SegmentHistoryBlock {
|
||||
compacted: boolean
|
||||
items: HistoryItem[]
|
||||
type: 'history-block'
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruction segment for explicit runtime or system guidance.
|
||||
*/
|
||||
export interface SegmentInstruction {
|
||||
priority?: 'critical' | 'high' | 'low' | 'normal'
|
||||
text: string
|
||||
type: 'instruction'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference segment for stable pointers to prior messages or resources.
|
||||
*/
|
||||
export interface SegmentReference {
|
||||
type: 'reference'
|
||||
note?: string
|
||||
refType: string
|
||||
targetId: string
|
||||
note?: string
|
||||
type: 'reference'
|
||||
}
|
||||
|
||||
/**
|
||||
* State snapshot segment for deterministic state serialization.
|
||||
*/
|
||||
export interface SegmentStateSnapshot {
|
||||
payload: Record<string, unknown>
|
||||
stateType: string
|
||||
type: 'state-snapshot'
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary segment for historical or narrative windows.
|
||||
*/
|
||||
export interface SegmentSummary {
|
||||
metadata?: Record<string, unknown>
|
||||
text: string
|
||||
type: 'summary'
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged text segment that preserves semantic tag boundaries.
|
||||
*/
|
||||
export interface SegmentTaggedText {
|
||||
tag: string
|
||||
text: string
|
||||
type: 'tagged-text'
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain text segment for projected message rendering.
|
||||
*/
|
||||
export interface SegmentText {
|
||||
text: string
|
||||
type: 'text'
|
||||
}
|
||||
|
||||
@@ -3,6 +3,71 @@ import type { ToolMessage } from '@xsai/shared-chat'
|
||||
import type { AgentHookRegistry, ChatHookRegistry } from '../contracts/hook-types'
|
||||
import type { ChatStreamEventContext, StreamingAssistantMessage } from '../types/chat'
|
||||
|
||||
export function createAgentHooks<TContext, TAssistantMessage, TToolCall>(): AgentHookRegistry<TContext, TAssistantMessage, TToolCall> {
|
||||
const onBeforeMessageComposedHooks: Array<(message: string, context: Omit<TContext, 'composedMessage'>) => Promise<void>> = []
|
||||
const onAfterMessageComposedHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onBeforeSendHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onAfterSendHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onTokenLiteralHooks: Array<(literal: string, context: TContext) => Promise<void>> = []
|
||||
const onTokenSpecialHooks: Array<(special: string, context: TContext) => Promise<void>> = []
|
||||
const onStreamEndHooks: Array<(context: TContext) => Promise<void>> = []
|
||||
const onAssistantResponseEndHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onAssistantMessageHooks: Array<(message: TAssistantMessage, messageText: string, context: TContext) => Promise<void>> = []
|
||||
const onChatTurnCompleteHooks: Array<(chat: { output: TAssistantMessage, outputText: string, toolCalls: TToolCall[] }, context: TContext) => Promise<void>> = []
|
||||
|
||||
function createSubscribe<T>(bucket: T[], cb: T) {
|
||||
bucket.push(cb)
|
||||
return () => {
|
||||
const index = bucket.indexOf(cb)
|
||||
if (index >= 0)
|
||||
bucket.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearHooks() {
|
||||
onBeforeMessageComposedHooks.length = 0
|
||||
onAfterMessageComposedHooks.length = 0
|
||||
onBeforeSendHooks.length = 0
|
||||
onAfterSendHooks.length = 0
|
||||
onTokenLiteralHooks.length = 0
|
||||
onTokenSpecialHooks.length = 0
|
||||
onStreamEndHooks.length = 0
|
||||
onAssistantResponseEndHooks.length = 0
|
||||
onAssistantMessageHooks.length = 0
|
||||
onChatTurnCompleteHooks.length = 0
|
||||
}
|
||||
|
||||
async function emitHooks<T extends any[]>(hooks: Array<(...args: T) => Promise<void>>, ...args: T) {
|
||||
for (const hook of hooks)
|
||||
await hook(...args)
|
||||
}
|
||||
|
||||
return {
|
||||
clearHooks,
|
||||
emitAfterMessageComposedHooks: (message, context) => emitHooks(onAfterMessageComposedHooks, message, context),
|
||||
emitAfterSendHooks: (message, context) => emitHooks(onAfterSendHooks, message, context),
|
||||
emitAssistantMessageHooks: (message, messageText, context) => emitHooks(onAssistantMessageHooks, message, messageText, context),
|
||||
emitAssistantResponseEndHooks: (message, context) => emitHooks(onAssistantResponseEndHooks, message, context),
|
||||
emitBeforeMessageComposedHooks: (message, context) => emitHooks(onBeforeMessageComposedHooks, message, context),
|
||||
emitBeforeSendHooks: (message, context) => emitHooks(onBeforeSendHooks, message, context),
|
||||
emitChatTurnCompleteHooks: (chat, context) => emitHooks(onChatTurnCompleteHooks, chat, context),
|
||||
emitStreamEndHooks: context => emitHooks(onStreamEndHooks, context),
|
||||
emitTokenLiteralHooks: (literal, context) => emitHooks(onTokenLiteralHooks, literal, context),
|
||||
|
||||
emitTokenSpecialHooks: (special, context) => emitHooks(onTokenSpecialHooks, special, context),
|
||||
onAfterMessageComposed: cb => createSubscribe(onAfterMessageComposedHooks, cb),
|
||||
onAfterSend: cb => createSubscribe(onAfterSendHooks, cb),
|
||||
onAssistantMessage: cb => createSubscribe(onAssistantMessageHooks, cb),
|
||||
onAssistantResponseEnd: cb => createSubscribe(onAssistantResponseEndHooks, cb),
|
||||
onBeforeMessageComposed: cb => createSubscribe(onBeforeMessageComposedHooks, cb),
|
||||
onBeforeSend: cb => createSubscribe(onBeforeSendHooks, cb),
|
||||
onChatTurnComplete: cb => createSubscribe(onChatTurnCompleteHooks, cb),
|
||||
onStreamEnd: cb => createSubscribe(onStreamEndHooks, cb),
|
||||
onTokenLiteral: cb => createSubscribe(onTokenLiteralHooks, cb),
|
||||
onTokenSpecial: cb => createSubscribe(onTokenSpecialHooks, cb),
|
||||
}
|
||||
}
|
||||
|
||||
export function createChatHooks(): ChatHookRegistry {
|
||||
const onBeforeMessageComposedHooks: Array<(message: string, context: Omit<ChatStreamEventContext, 'composedMessage'>) => Promise<void>> = []
|
||||
const onAfterMessageComposedHooks: Array<(message: string, context: ChatStreamEventContext) => Promise<void>> = []
|
||||
@@ -169,91 +234,26 @@ export function createChatHooks(): ChatHookRegistry {
|
||||
}
|
||||
|
||||
return {
|
||||
onBeforeMessageComposed,
|
||||
onAfterMessageComposed,
|
||||
onBeforeSend,
|
||||
onAfterSend,
|
||||
onTokenLiteral,
|
||||
onTokenSpecial,
|
||||
onStreamEnd,
|
||||
onAssistantResponseEnd,
|
||||
onAssistantMessage,
|
||||
onChatTurnComplete,
|
||||
emitBeforeMessageComposedHooks,
|
||||
clearHooks,
|
||||
emitAfterMessageComposedHooks,
|
||||
emitBeforeSendHooks,
|
||||
emitAfterSendHooks,
|
||||
emitAssistantMessageHooks,
|
||||
emitAssistantResponseEndHooks,
|
||||
emitBeforeMessageComposedHooks,
|
||||
emitBeforeSendHooks,
|
||||
emitChatTurnCompleteHooks,
|
||||
emitStreamEndHooks,
|
||||
emitTokenLiteralHooks,
|
||||
emitTokenSpecialHooks,
|
||||
emitStreamEndHooks,
|
||||
emitAssistantResponseEndHooks,
|
||||
emitAssistantMessageHooks,
|
||||
emitChatTurnCompleteHooks,
|
||||
clearHooks,
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentHooks<TContext, TAssistantMessage, TToolCall>(): AgentHookRegistry<TContext, TAssistantMessage, TToolCall> {
|
||||
const onBeforeMessageComposedHooks: Array<(message: string, context: Omit<TContext, 'composedMessage'>) => Promise<void>> = []
|
||||
const onAfterMessageComposedHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onBeforeSendHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onAfterSendHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onTokenLiteralHooks: Array<(literal: string, context: TContext) => Promise<void>> = []
|
||||
const onTokenSpecialHooks: Array<(special: string, context: TContext) => Promise<void>> = []
|
||||
const onStreamEndHooks: Array<(context: TContext) => Promise<void>> = []
|
||||
const onAssistantResponseEndHooks: Array<(message: string, context: TContext) => Promise<void>> = []
|
||||
const onAssistantMessageHooks: Array<(message: TAssistantMessage, messageText: string, context: TContext) => Promise<void>> = []
|
||||
const onChatTurnCompleteHooks: Array<(chat: { output: TAssistantMessage, outputText: string, toolCalls: TToolCall[] }, context: TContext) => Promise<void>> = []
|
||||
|
||||
function createSubscribe<T>(bucket: T[], cb: T) {
|
||||
bucket.push(cb)
|
||||
return () => {
|
||||
const index = bucket.indexOf(cb)
|
||||
if (index >= 0)
|
||||
bucket.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearHooks() {
|
||||
onBeforeMessageComposedHooks.length = 0
|
||||
onAfterMessageComposedHooks.length = 0
|
||||
onBeforeSendHooks.length = 0
|
||||
onAfterSendHooks.length = 0
|
||||
onTokenLiteralHooks.length = 0
|
||||
onTokenSpecialHooks.length = 0
|
||||
onStreamEndHooks.length = 0
|
||||
onAssistantResponseEndHooks.length = 0
|
||||
onAssistantMessageHooks.length = 0
|
||||
onChatTurnCompleteHooks.length = 0
|
||||
}
|
||||
|
||||
async function emitHooks<T extends any[]>(hooks: Array<(...args: T) => Promise<void>>, ...args: T) {
|
||||
for (const hook of hooks)
|
||||
await hook(...args)
|
||||
}
|
||||
|
||||
return {
|
||||
onBeforeMessageComposed: cb => createSubscribe(onBeforeMessageComposedHooks, cb),
|
||||
onAfterMessageComposed: cb => createSubscribe(onAfterMessageComposedHooks, cb),
|
||||
onBeforeSend: cb => createSubscribe(onBeforeSendHooks, cb),
|
||||
onAfterSend: cb => createSubscribe(onAfterSendHooks, cb),
|
||||
onTokenLiteral: cb => createSubscribe(onTokenLiteralHooks, cb),
|
||||
onTokenSpecial: cb => createSubscribe(onTokenSpecialHooks, cb),
|
||||
onStreamEnd: cb => createSubscribe(onStreamEndHooks, cb),
|
||||
onAssistantResponseEnd: cb => createSubscribe(onAssistantResponseEndHooks, cb),
|
||||
onAssistantMessage: cb => createSubscribe(onAssistantMessageHooks, cb),
|
||||
onChatTurnComplete: cb => createSubscribe(onChatTurnCompleteHooks, cb),
|
||||
|
||||
emitBeforeMessageComposedHooks: (message, context) => emitHooks(onBeforeMessageComposedHooks, message, context),
|
||||
emitAfterMessageComposedHooks: (message, context) => emitHooks(onAfterMessageComposedHooks, message, context),
|
||||
emitBeforeSendHooks: (message, context) => emitHooks(onBeforeSendHooks, message, context),
|
||||
emitAfterSendHooks: (message, context) => emitHooks(onAfterSendHooks, message, context),
|
||||
emitTokenLiteralHooks: (literal, context) => emitHooks(onTokenLiteralHooks, literal, context),
|
||||
emitTokenSpecialHooks: (special, context) => emitHooks(onTokenSpecialHooks, special, context),
|
||||
emitStreamEndHooks: context => emitHooks(onStreamEndHooks, context),
|
||||
emitAssistantResponseEndHooks: (message, context) => emitHooks(onAssistantResponseEndHooks, message, context),
|
||||
emitAssistantMessageHooks: (message, messageText, context) => emitHooks(onAssistantMessageHooks, message, messageText, context),
|
||||
emitChatTurnCompleteHooks: (chat, context) => emitHooks(onChatTurnCompleteHooks, chat, context),
|
||||
clearHooks,
|
||||
onAfterMessageComposed,
|
||||
onAfterSend,
|
||||
onAssistantMessage,
|
||||
onAssistantResponseEnd,
|
||||
onBeforeMessageComposed,
|
||||
onBeforeSend,
|
||||
onChatTurnComplete,
|
||||
onStreamEnd,
|
||||
onTokenLiteral,
|
||||
onTokenSpecial,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ function createHarness() {
|
||||
const sessionMessages: Record<string, ChatHistoryItem[]> = {
|
||||
'session-1': [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system prompt',
|
||||
createdAt: new Date(2026, 3, 25, 18, 0).getTime(),
|
||||
id: 'system',
|
||||
role: 'system',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -35,20 +35,20 @@ function createHarness() {
|
||||
const assistantTurns: unknown[] = []
|
||||
const stateChanges: unknown[] = []
|
||||
const telemetry = {
|
||||
assistantResponseRendered: [] as unknown[],
|
||||
chatActivationFailed: [] as unknown[],
|
||||
chatActivationStarted: [] as unknown[],
|
||||
chatActivationSucceeded: [] as unknown[],
|
||||
chatActivationFailed: [] as unknown[],
|
||||
messageSendStarted: [] as unknown[],
|
||||
llmRequestStarted: [] as unknown[],
|
||||
llmFirstToken: [] as unknown[],
|
||||
assistantResponseRendered: [] as unknown[],
|
||||
llmGeneration: [] as unknown[],
|
||||
llmRequestStarted: [] as unknown[],
|
||||
messageRound: [] as unknown[],
|
||||
messageRoundFailed: [] as unknown[],
|
||||
messageSendStarted: [] as unknown[],
|
||||
}
|
||||
const stream = vi.fn(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options?: StreamOptions) => {
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'assistant reply' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: 'assistant reply', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
const ids = ['stream-context', 'assistant-id', 'user-id', 'fallback-id']
|
||||
let systemPromptSupplement: string | undefined
|
||||
@@ -57,51 +57,51 @@ function createHarness() {
|
||||
let generation = 1
|
||||
|
||||
const runtime = createChatOrchestratorRuntime({
|
||||
session: {
|
||||
ensureSession: (sessionId) => {
|
||||
sessionMessages[sessionId] ??= []
|
||||
},
|
||||
getSessionMessages: sessionId => sessionMessages[sessionId] ?? [],
|
||||
appendSessionMessage: (sessionId, message) => {
|
||||
sessionMessages[sessionId] ??= []
|
||||
sessionMessages[sessionId].push(message)
|
||||
},
|
||||
getSessionGeneration: () => generation,
|
||||
},
|
||||
context: {
|
||||
ingest: vi.fn(),
|
||||
snapshot: () => structuredClone(contextSnapshot),
|
||||
},
|
||||
createId: () => ids.shift() ?? 'generated-id',
|
||||
foregroundStream: {
|
||||
patch: message => foregroundPatches.push(message),
|
||||
reset: () => foregroundResets.push({ role: 'assistant', content: '', slices: [], tool_results: [] }),
|
||||
reset: () => foregroundResets.push({ content: '', role: 'assistant', slices: [], tool_results: [] }),
|
||||
},
|
||||
getActiveProvider: () => 'mock-provider',
|
||||
getActiveSessionId: () => 'session-1',
|
||||
getSystemPromptSupplement: () => systemPromptSupplement,
|
||||
llm: {
|
||||
stream,
|
||||
},
|
||||
getActiveSessionId: () => 'session-1',
|
||||
getActiveProvider: () => 'mock-provider',
|
||||
getSystemPromptSupplement: () => systemPromptSupplement,
|
||||
now: () => nowValue,
|
||||
monotonicNow: () => monotonicNowValues.shift() ?? 1000,
|
||||
createId: () => ids.shift() ?? 'generated-id',
|
||||
onLifecycle: record => lifecycleRecords.push(record),
|
||||
onPromptProjection: payload => promptProjections.push(payload),
|
||||
onUserMessageAppended: event => userAppended.push(event),
|
||||
now: () => nowValue,
|
||||
onAssistantMessageAppended: event => assistantAppended.push(event),
|
||||
onUserTurnReady: event => userTurns.push(event),
|
||||
onAssistantResponseRendered: event => telemetry.assistantResponseRendered.push(event),
|
||||
onAssistantTurnReady: event => assistantTurns.push(event),
|
||||
onStateChange: state => stateChanges.push(state),
|
||||
onChatActivationFailed: event => telemetry.chatActivationFailed.push(event),
|
||||
onChatActivationStarted: event => telemetry.chatActivationStarted.push(event),
|
||||
onChatActivationSucceeded: event => telemetry.chatActivationSucceeded.push(event),
|
||||
onChatActivationFailed: event => telemetry.chatActivationFailed.push(event),
|
||||
onMessageSendStarted: event => telemetry.messageSendStarted.push(event),
|
||||
onLlmRequestStarted: event => telemetry.llmRequestStarted.push(event),
|
||||
onLifecycle: record => lifecycleRecords.push(record),
|
||||
onLlmFirstToken: event => telemetry.llmFirstToken.push(event),
|
||||
onAssistantResponseRendered: event => telemetry.assistantResponseRendered.push(event),
|
||||
onLlmGeneration: event => telemetry.llmGeneration.push(event),
|
||||
onLlmRequestStarted: event => telemetry.llmRequestStarted.push(event),
|
||||
onMessageRound: event => telemetry.messageRound.push(event),
|
||||
onMessageRoundFailed: event => telemetry.messageRoundFailed.push(event),
|
||||
onMessageSendStarted: event => telemetry.messageSendStarted.push(event),
|
||||
onPromptProjection: payload => promptProjections.push(payload),
|
||||
onStateChange: state => stateChanges.push(state),
|
||||
onUserMessageAppended: event => userAppended.push(event),
|
||||
onUserTurnReady: event => userTurns.push(event),
|
||||
session: {
|
||||
appendSessionMessage: (sessionId, message) => {
|
||||
sessionMessages[sessionId] ??= []
|
||||
sessionMessages[sessionId].push(message)
|
||||
},
|
||||
ensureSession: (sessionId) => {
|
||||
sessionMessages[sessionId] ??= []
|
||||
},
|
||||
getSessionGeneration: () => generation,
|
||||
getSessionMessages: sessionId => sessionMessages[sessionId] ?? [],
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -116,16 +116,16 @@ function createHarness() {
|
||||
},
|
||||
},
|
||||
lifecycleRecords,
|
||||
now: {
|
||||
set: (next: number) => {
|
||||
nowValue = next
|
||||
},
|
||||
},
|
||||
monotonicNow: {
|
||||
set: (next: number[]) => {
|
||||
monotonicNowValues = [...next]
|
||||
},
|
||||
},
|
||||
now: {
|
||||
set: (next: number) => {
|
||||
nowValue = next
|
||||
},
|
||||
},
|
||||
promptProjections,
|
||||
runtime,
|
||||
sessionMessages,
|
||||
@@ -155,15 +155,15 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||
for (const text of '1234567890')
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text })
|
||||
await options?.onStreamEvent?.({ text, type: 'text-delta' })
|
||||
|
||||
patchesBeforeFinish = harness.foregroundPatches.length
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('show a slow response', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(patchesBeforeFinish).toBeGreaterThan(1)
|
||||
@@ -174,8 +174,8 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.runtime.ingest('use a widget', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
toolReferences: [{ name: 'stage_widgets' }],
|
||||
})
|
||||
|
||||
@@ -202,53 +202,53 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, messages, options) => {
|
||||
await options?.onStreamEvent?.({
|
||||
type: 'tool-call',
|
||||
args: '{}',
|
||||
toolCallId: 'call-weather',
|
||||
toolName: 'weather',
|
||||
args: '{}',
|
||||
type: 'tool-call',
|
||||
} as StreamEvent)
|
||||
await options?.onStreamEvent?.({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-weather',
|
||||
result: 'sunny',
|
||||
toolCallId: 'call-weather',
|
||||
type: 'tool-result',
|
||||
} as StreamEvent)
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'The weather is sunny.' })
|
||||
await options?.onStreamEvent?.({ text: 'The weather is sunny.', type: 'text-delta' })
|
||||
|
||||
await (options as StreamOptions & { onMessages?: (messages: Message[]) => void })?.onMessages?.([
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{}',
|
||||
name: 'weather',
|
||||
},
|
||||
id: 'call-weather',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'weather',
|
||||
arguments: '{}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content: 'sunny',
|
||||
role: 'tool',
|
||||
tool_call_id: 'call-weather',
|
||||
content: 'sunny',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'The weather is sunny.',
|
||||
role: 'assistant',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('What is the weather?', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
await harness.runtime.ingest('Can you repeat that?', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
const messages = harness.stream.mock.calls[1]?.[2]
|
||||
@@ -265,23 +265,23 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{}',
|
||||
name: 'weather',
|
||||
},
|
||||
id: 'call-weather',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'weather',
|
||||
arguments: '{}',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(messages?.[3]).toEqual({
|
||||
content: 'sunny',
|
||||
role: 'tool',
|
||||
tool_call_id: 'call-weather',
|
||||
content: 'sunny',
|
||||
})
|
||||
expect(messages?.[4]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'The weather is sunny.',
|
||||
role: 'assistant',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -289,11 +289,11 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
const harness = createHarness()
|
||||
harness.contextSnapshot['system:weather'] = [
|
||||
{
|
||||
id: 'weather',
|
||||
contextId: 'system:weather',
|
||||
createdAt: 1,
|
||||
id: 'weather',
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: 'sunny',
|
||||
createdAt: 1,
|
||||
},
|
||||
]
|
||||
const hookOrder: string[] = []
|
||||
@@ -328,13 +328,13 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
})
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, messages, options) => {
|
||||
composedMessages = messages
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'hello' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: 'hello', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('hello from user', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(hookOrder).toEqual([
|
||||
@@ -349,16 +349,16 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
'turn-complete',
|
||||
])
|
||||
expect(composedMessages).toHaveLength(2)
|
||||
expect(composedMessages[0]).toMatchObject({ role: 'system', content: 'system prompt' })
|
||||
expect(composedMessages[0]).toMatchObject({ content: 'system prompt', role: 'system' })
|
||||
expect(composedMessages[1]).toMatchObject({ role: 'user' })
|
||||
expect(composedMessages[1]?.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: '[2026-04-25 18:47] hello from user',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '\n[Context]\n- system:weather: sunny',
|
||||
type: 'text',
|
||||
},
|
||||
])
|
||||
expect(harness.lifecycleRecords).toEqual(expect.arrayContaining([
|
||||
@@ -382,13 +382,13 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
specialTurnId = context.turnId
|
||||
})
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: '<|CALL ["plugin.action"]|>' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: '<|CALL ["plugin.action"]|>', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('trigger special', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(specialTurnId).toBe('user-id')
|
||||
@@ -400,12 +400,12 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
it('keeps timestamp prefixes stable for legacy user messages without createdAt', async () => {
|
||||
const harness = createHarness()
|
||||
const legacyUserMessage: ChatHistoryItem = {
|
||||
role: 'user' as const,
|
||||
content: 'legacy prompt',
|
||||
id: 'legacy-user',
|
||||
role: 'user' as const,
|
||||
}
|
||||
harness.sessionMessages['session-1'] = [
|
||||
{ role: 'system', content: 'system prompt', createdAt: 1, id: 'system' },
|
||||
{ content: 'system prompt', createdAt: 1, id: 'system', role: 'system' },
|
||||
legacyUserMessage,
|
||||
]
|
||||
const firstMessages: Message[][] = []
|
||||
@@ -413,24 +413,24 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, messages, options) => {
|
||||
firstMessages.push(structuredClone(messages))
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
harness.now.set(new Date(2026, 3, 25, 18, 47).getTime())
|
||||
|
||||
await harness.runtime.ingest('first send', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, messages, options) => {
|
||||
secondMessages.push(structuredClone(messages))
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
harness.now.set(new Date(2026, 3, 25, 19, 12).getTime())
|
||||
|
||||
await harness.runtime.ingest('second send', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(firstMessages[0]?.[1]?.content).toBe('[2026-04-25 18:47] legacy prompt')
|
||||
@@ -444,18 +444,18 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
harness.systemPromptSupplement.set('Plugin toolset guidance.')
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, messages, options) => {
|
||||
composedMessages = messages
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'hello' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: 'hello', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('hello from user', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(composedMessages[0]).toMatchObject({
|
||||
role: 'system',
|
||||
content: 'system prompt\n\nPlugin toolset guidance.',
|
||||
role: 'system',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -466,18 +466,18 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
harness.systemPromptSupplement.set('Plugin toolset guidance.')
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, messages, options) => {
|
||||
composedMessages = messages
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'hello' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: 'hello', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('hello from user', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(composedMessages[0]).toMatchObject({
|
||||
role: 'system',
|
||||
content: 'Plugin toolset guidance.',
|
||||
role: 'system',
|
||||
})
|
||||
expect(composedMessages[1]).toMatchObject({ role: 'user' })
|
||||
})
|
||||
@@ -486,75 +486,75 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
const harness = createHarness()
|
||||
harness.monotonicNow.set([100, 150, 250, 400, 460])
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'assistant reply' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: 'assistant reply', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
await options?.onUsage?.({
|
||||
inputTokens: 12,
|
||||
outputTokens: 8,
|
||||
totalTokens: 20,
|
||||
source: 'reported',
|
||||
totalTokens: 20,
|
||||
})
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('hello from voice', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
input: {
|
||||
type: 'input:text:voice',
|
||||
data: {
|
||||
transcription: 'hello from voice',
|
||||
},
|
||||
type: 'input:text:voice',
|
||||
},
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(harness.telemetry.messageSendStarted).toEqual([{
|
||||
conversationId: 'session-1',
|
||||
model: 'gpt-test',
|
||||
roundId: 'user-id',
|
||||
source: 'voice',
|
||||
model: 'gpt-test',
|
||||
turnIndex: 1,
|
||||
}])
|
||||
expect(harness.telemetry.llmRequestStarted).toEqual([{
|
||||
conversationId: 'session-1',
|
||||
roundId: 'user-id',
|
||||
hasVoice: true,
|
||||
model: 'gpt-test',
|
||||
provider: 'mock-provider',
|
||||
hasVoice: true,
|
||||
roundId: 'user-id',
|
||||
turnIndex: 1,
|
||||
}])
|
||||
expect(harness.telemetry.llmFirstToken).toEqual([{
|
||||
conversationId: 'session-1',
|
||||
roundId: 'user-id',
|
||||
model: 'gpt-test',
|
||||
roundId: 'user-id',
|
||||
ttfbMs: 100,
|
||||
turnIndex: 1,
|
||||
}])
|
||||
expect(harness.telemetry.assistantResponseRendered).toEqual([{
|
||||
conversationId: 'session-1',
|
||||
roundId: 'user-id',
|
||||
model: 'gpt-test',
|
||||
latencyMs: 250,
|
||||
model: 'gpt-test',
|
||||
roundId: 'user-id',
|
||||
turnIndex: 1,
|
||||
}])
|
||||
expect(harness.telemetry.llmGeneration).toEqual([{
|
||||
conversationId: 'session-1',
|
||||
roundId: 'user-id',
|
||||
model: 'gpt-test',
|
||||
provider: 'mock-provider',
|
||||
inputTokens: 12,
|
||||
model: 'gpt-test',
|
||||
outputTokens: 8,
|
||||
provider: 'mock-provider',
|
||||
roundId: 'user-id',
|
||||
totalTokens: 20,
|
||||
usageSource: 'reported',
|
||||
turnIndex: 1,
|
||||
usageSource: 'reported',
|
||||
}])
|
||||
expect(harness.telemetry.messageRound).toEqual([{
|
||||
conversationId: 'session-1',
|
||||
roundId: 'user-id',
|
||||
durationMs: 360,
|
||||
hasVoice: true,
|
||||
inputTokens: 12,
|
||||
model: 'gpt-test',
|
||||
outputTokens: 8,
|
||||
roundId: 'user-id',
|
||||
totalTokens: 20,
|
||||
turnIndex: 1,
|
||||
usageSource: 'reported',
|
||||
@@ -584,14 +584,14 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.runtime.ingest('hello from text input', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
input: {
|
||||
type: 'input:text',
|
||||
data: {
|
||||
text: 'hello from text input',
|
||||
},
|
||||
type: 'input:text',
|
||||
},
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(harness.telemetry.messageSendStarted).toEqual([
|
||||
@@ -617,12 +617,12 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.runtime.ingest('first turn', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
await harness.runtime.ingest('second turn', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(harness.telemetry.chatActivationStarted).toHaveLength(1)
|
||||
@@ -637,8 +637,8 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
harness.stream.mockRejectedValueOnce(new Error('provider rejected with sensitive details'))
|
||||
|
||||
await expect(harness.runtime.ingest('hello', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})).rejects.toThrow('provider rejected')
|
||||
|
||||
expect(harness.telemetry.chatActivationStarted).toEqual([{
|
||||
@@ -676,14 +676,14 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.runtime.ingest('first turn succeeds', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
harness.stream.mockRejectedValueOnce(new Error('later turn rejected'))
|
||||
|
||||
await expect(harness.runtime.ingest('second turn fails', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})).rejects.toThrow('later turn rejected')
|
||||
|
||||
expect(harness.telemetry.chatActivationFailed).toEqual([])
|
||||
@@ -708,12 +708,12 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
})
|
||||
|
||||
const firstSend = harness.runtime.ingest('hold queue', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
const secondSend = harness.runtime.ingest('cancel me', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -752,16 +752,16 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
options?.onUsage?.({
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
source: 'reported',
|
||||
totalTokens: 2,
|
||||
})
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'deleted reply' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: 'deleted reply', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
|
||||
const pendingSend = harness.runtime.ingest('delete this chat', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -790,12 +790,12 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
})
|
||||
|
||||
const firstSend = harness.runtime.ingest('hold queue', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
const secondSend = harness.runtime.ingest('stale request', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -820,8 +820,8 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
expect(harness.stateChanges.at(-1)).toEqual({
|
||||
activeSendSessionId: 'session-1',
|
||||
activeStreamingMessage: undefined,
|
||||
sending: true,
|
||||
pendingQueuedSendCount: 0,
|
||||
sending: true,
|
||||
})
|
||||
|
||||
harness.runtime.setSending(false)
|
||||
@@ -829,8 +829,8 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
expect(harness.stateChanges.at(-1)).toEqual({
|
||||
activeSendSessionId: undefined,
|
||||
activeStreamingMessage: undefined,
|
||||
sending: false,
|
||||
pendingQueuedSendCount: 0,
|
||||
sending: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -844,26 +844,26 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
const harness = createHarness()
|
||||
let finishSend: (() => void) | undefined
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'background reply' })
|
||||
await options?.onStreamEvent?.({ text: 'background reply', type: 'text-delta' })
|
||||
await new Promise<void>((resolve) => {
|
||||
finishSend = resolve
|
||||
})
|
||||
})
|
||||
|
||||
const pendingSend = harness.runtime.ingest('background request', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
}, 'session-2')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.stateChanges).toContainEqual(expect.objectContaining({
|
||||
activeSendSessionId: 'session-2',
|
||||
activeStreamingMessage: expect.objectContaining({
|
||||
role: 'assistant',
|
||||
createdAt: expect.any(Number),
|
||||
role: 'assistant',
|
||||
}),
|
||||
sending: true,
|
||||
pendingQueuedSendCount: 0,
|
||||
sending: true,
|
||||
}))
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
@@ -882,8 +882,8 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
expect(harness.stateChanges.at(-1)).toEqual({
|
||||
activeSendSessionId: undefined,
|
||||
activeStreamingMessage: undefined,
|
||||
sending: false,
|
||||
pendingQueuedSendCount: 0,
|
||||
sending: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -898,25 +898,25 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
|
||||
const queuedMessage = 'queued-message-'.repeat(12)
|
||||
const firstSend = harness.runtime.ingest('hold queue', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
const secondSend = harness.runtime.ingest(queuedMessage, {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
attachments: [
|
||||
{
|
||||
type: 'image',
|
||||
data: 'aW1hZ2U=',
|
||||
mimeType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
],
|
||||
chatProvider: provider,
|
||||
input: {
|
||||
type: 'input:text',
|
||||
data: {
|
||||
text: 'queued input',
|
||||
},
|
||||
type: 'input:text',
|
||||
},
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -928,12 +928,12 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
|
||||
expect(harness.runtime.getPendingQueuedSendSnapshot()).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
generation: 1,
|
||||
cancelled: false,
|
||||
messagePreview: queuedMessage.slice(0, 120),
|
||||
generation: 1,
|
||||
hasAttachments: true,
|
||||
inputType: 'input:text',
|
||||
messagePreview: queuedMessage.slice(0, 120),
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
])
|
||||
|
||||
@@ -949,71 +949,71 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
let composedMessages: Message[] = []
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, messages, options) => {
|
||||
composedMessages = messages
|
||||
await options?.onStreamEvent?.({ type: 'reasoning-delta', text: 'thinking' })
|
||||
await options?.onStreamEvent?.({ text: 'thinking', type: 'reasoning-delta' })
|
||||
await options?.onStreamEvent?.({
|
||||
type: 'tool-call',
|
||||
args: {},
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'weather',
|
||||
args: {},
|
||||
type: 'tool-call',
|
||||
} as StreamEvent)
|
||||
await options?.onStreamEvent?.({
|
||||
type: 'tool-result',
|
||||
toolCallId: 'tool-1',
|
||||
result: 'sunny',
|
||||
toolCallId: 'tool-1',
|
||||
type: 'tool-result',
|
||||
} as StreamEvent)
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'visible reply' })
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
await options?.onStreamEvent?.({ text: 'visible reply', type: 'text-delta' })
|
||||
await options?.onStreamEvent?.({ finishReason: 'stop', type: 'finish' })
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('see image', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
attachments: [
|
||||
{
|
||||
type: 'image',
|
||||
data: 'aW1hZ2U=',
|
||||
mimeType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
],
|
||||
chatProvider: provider,
|
||||
model: 'gpt-test',
|
||||
})
|
||||
|
||||
expect(composedMessages[1]?.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: '[2026-04-25 18:47] see image',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/png;base64,aW1hZ2U=',
|
||||
},
|
||||
type: 'image_url',
|
||||
},
|
||||
])
|
||||
const assistant = harness.sessionMessages['session-1']?.at(-1)
|
||||
expect(assistant).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'visible reply',
|
||||
categorization: {
|
||||
reasoning: 'thinking',
|
||||
},
|
||||
content: 'visible reply',
|
||||
role: 'assistant',
|
||||
})
|
||||
expect((assistant as StreamingAssistantMessage).slices).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'tool-call',
|
||||
toolCall: expect.objectContaining({
|
||||
toolCallId: 'tool-1',
|
||||
}),
|
||||
type: 'tool-call',
|
||||
}),
|
||||
{
|
||||
type: 'text',
|
||||
text: 'visible reply',
|
||||
type: 'text',
|
||||
},
|
||||
])
|
||||
expect((assistant as StreamingAssistantMessage).tool_results).toEqual([
|
||||
{
|
||||
type: 'tool-call-result',
|
||||
id: 'tool-1',
|
||||
result: 'sunny',
|
||||
type: 'tool-call-result',
|
||||
},
|
||||
])
|
||||
expect(harness.assistantAppended).toHaveLength(1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,30 +7,30 @@ import { createContextRegistry } from './context-registry'
|
||||
|
||||
type TestContextMessage = ContextMessage & { source?: string }
|
||||
|
||||
function createMetadata(extensionId: string, moduleId: string): NonNullable<ContextMessage['metadata']> {
|
||||
return {
|
||||
source: {
|
||||
id: moduleId,
|
||||
extension: {
|
||||
id: extensionId,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createContextMessage(overrides: Partial<TestContextMessage> = {}): TestContextMessage {
|
||||
const id = overrides.id ?? 'context-1'
|
||||
|
||||
return {
|
||||
id,
|
||||
contextId: overrides.contextId ?? id,
|
||||
createdAt: overrides.createdAt ?? 1,
|
||||
id,
|
||||
strategy: overrides.strategy ?? ContextUpdateStrategy.ReplaceSelf,
|
||||
text: overrides.text ?? 'context text',
|
||||
createdAt: overrides.createdAt ?? 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMetadata(extensionId: string, moduleId: string): NonNullable<ContextMessage['metadata']> {
|
||||
return {
|
||||
source: {
|
||||
extension: {
|
||||
id: extensionId,
|
||||
},
|
||||
id: moduleId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* const registry = createContextRegistry()
|
||||
@@ -56,14 +56,14 @@ describe('createContextRegistry', () => {
|
||||
}))
|
||||
|
||||
expect(firstResult).toEqual({
|
||||
sourceKey: 'sensor',
|
||||
mutation: 'replace',
|
||||
entryCount: 1,
|
||||
mutation: 'replace',
|
||||
sourceKey: 'sensor',
|
||||
})
|
||||
expect(secondResult).toEqual({
|
||||
sourceKey: 'sensor',
|
||||
mutation: 'replace',
|
||||
entryCount: 1,
|
||||
mutation: 'replace',
|
||||
sourceKey: 'sensor',
|
||||
})
|
||||
expect(registry.snapshot().sensor?.map(message => message.text)).toEqual(['second reading'])
|
||||
expect(registry.contextHistory().map(message => message.id)).toEqual(['first', 'second'])
|
||||
@@ -90,14 +90,14 @@ describe('createContextRegistry', () => {
|
||||
}))
|
||||
|
||||
expect(firstResult).toEqual({
|
||||
sourceKey: 'sensor',
|
||||
mutation: 'append',
|
||||
entryCount: 1,
|
||||
mutation: 'append',
|
||||
sourceKey: 'sensor',
|
||||
})
|
||||
expect(secondResult).toEqual({
|
||||
sourceKey: 'sensor',
|
||||
mutation: 'append',
|
||||
entryCount: 2,
|
||||
mutation: 'append',
|
||||
sourceKey: 'sensor',
|
||||
})
|
||||
expect(registry.snapshot().sensor?.map(message => message.text)).toEqual(['first reading', 'second reading'])
|
||||
})
|
||||
@@ -111,8 +111,8 @@ describe('createContextRegistry', () => {
|
||||
|
||||
const extensionModuleResult = registry.ingest(createContextMessage({
|
||||
id: 'with-instance',
|
||||
source: 'fallback-source',
|
||||
metadata: createMetadata('weather', 'station-1'),
|
||||
source: 'fallback-source',
|
||||
}))
|
||||
const sourceResult = registry.ingest(createContextMessage({
|
||||
id: 'source-only',
|
||||
@@ -181,9 +181,9 @@ describe('createContextRegistry', () => {
|
||||
const snapshot = registry.snapshot()
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceKey: '__proto__',
|
||||
mutation: 'replace',
|
||||
entryCount: 1,
|
||||
mutation: 'replace',
|
||||
sourceKey: '__proto__',
|
||||
})
|
||||
expect(Object.getPrototypeOf(snapshot)).toBe(Object.prototype)
|
||||
expect(Object.hasOwn(snapshot, '__proto__')).toBe(true)
|
||||
@@ -212,9 +212,9 @@ describe('createContextRegistry', () => {
|
||||
|
||||
expect(firstResult?.entryCount).toBe(1)
|
||||
expect(secondResult).toEqual({
|
||||
sourceKey: 'toString',
|
||||
mutation: 'append',
|
||||
entryCount: 2,
|
||||
mutation: 'append',
|
||||
sourceKey: 'toString',
|
||||
})
|
||||
expect(Object.getOwnPropertyDescriptor(registry.snapshot(), 'toString')?.value?.map((message: ContextMessage) => message.text)).toEqual([
|
||||
'first toString bucket entry',
|
||||
@@ -288,9 +288,9 @@ describe('createContextRegistry', () => {
|
||||
}))
|
||||
|
||||
expect(() => registry.ingest(createContextMessage({
|
||||
content: () => 'functions cannot be structured-cloned',
|
||||
id: 'uncloneable',
|
||||
source: 'broken-source',
|
||||
content: () => 'functions cannot be structured-cloned',
|
||||
}))).toThrow()
|
||||
expect(registry.snapshot()).toEqual({
|
||||
sensor: [
|
||||
|
||||
@@ -5,11 +5,6 @@ import type { ContextMessage } from '../types/chat'
|
||||
const CONTEXT_UPDATE_REPLACE_SELF = 'replace-self'
|
||||
const CONTEXT_UPDATE_APPEND_SELF = 'append-self'
|
||||
|
||||
interface EventSourcePayload {
|
||||
source?: string
|
||||
metadata?: { source?: MetadataEventSource }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored context event with the registry bucket key resolved at ingest time.
|
||||
*/
|
||||
@@ -22,62 +17,48 @@ export interface ContextHistoryEntry extends ContextMessage {
|
||||
* Observable result emitted when a context update mutates an active bucket.
|
||||
*/
|
||||
export interface ContextIngestResult {
|
||||
/** Stable source bucket key affected by the ingest. */
|
||||
sourceKey: string
|
||||
/** Registry mutation applied to the active bucket. */
|
||||
mutation: 'replace' | 'append'
|
||||
/** Number of active entries in the affected bucket after mutation. */
|
||||
entryCount: number
|
||||
/** Registry mutation applied to the active bucket. */
|
||||
mutation: 'append' | 'replace'
|
||||
/** Stable source bucket key affected by the ingest. */
|
||||
sourceKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable runtime registry for active context buckets and bounded ingest history.
|
||||
*/
|
||||
export interface ContextRegistry {
|
||||
/** Returns cloned active context buckets for callers that prefer explicit naming. */
|
||||
activeContexts: () => Record<string, ContextMessage[]>
|
||||
/** Returns cloned ingest history entries in chronological order. */
|
||||
contextHistory: () => ContextHistoryEntry[]
|
||||
/** Stores a context message and returns a mutation summary for known strategies. */
|
||||
ingest: (envelope: ContextMessage) => ContextIngestResult | undefined
|
||||
/** Clears active context buckets and ingest history. */
|
||||
reset: () => void
|
||||
/** Returns a cloned active context bucket snapshot. */
|
||||
snapshot: () => Record<string, ContextMessage[]>
|
||||
/** Returns cloned active context buckets for callers that prefer explicit naming. */
|
||||
activeContexts: () => Record<string, ContextMessage[]>
|
||||
/** Returns cloned ingest history entries in chronological order. */
|
||||
contextHistory: () => ContextHistoryEntry[]
|
||||
}
|
||||
|
||||
interface CreateContextRegistryOptions {
|
||||
/**
|
||||
* Maximum number of history records retained by the registry.
|
||||
*
|
||||
* @default 400
|
||||
*/
|
||||
historyLimit?: number
|
||||
/**
|
||||
* Resolves a context message into a stable source bucket key.
|
||||
*
|
||||
* @default metadata extension/module key, then event source, then "unknown"
|
||||
*/
|
||||
getSourceKey?: (event: EventSourcePayload, fallback?: string) => string
|
||||
/**
|
||||
* Maximum number of history records retained by the registry.
|
||||
*
|
||||
* @default 400
|
||||
*/
|
||||
historyLimit?: number
|
||||
}
|
||||
|
||||
function formatMetadataSource(source?: MetadataEventSource) {
|
||||
if (!source)
|
||||
return undefined
|
||||
|
||||
if ('extension' in source) {
|
||||
return `${source.extension.id}:${source.id}`
|
||||
}
|
||||
|
||||
return source.id
|
||||
}
|
||||
|
||||
function defaultGetSourceKey(event: EventSourcePayload, fallback = 'unknown') {
|
||||
return (
|
||||
formatMetadataSource(event.metadata?.source)
|
||||
?? event.source
|
||||
?? fallback
|
||||
)
|
||||
interface EventSourcePayload {
|
||||
metadata?: { source?: MetadataEventSource }
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,17 +95,17 @@ export function createContextRegistry(options: CreateContextRegistryOptions = {}
|
||||
if (envelope.strategy === CONTEXT_UPDATE_REPLACE_SELF) {
|
||||
currentActiveContexts.set(sourceKey, [safeEnvelopeToStore])
|
||||
result = {
|
||||
sourceKey,
|
||||
mutation: 'replace',
|
||||
entryCount: currentActiveContexts.get(sourceKey)?.length ?? 0,
|
||||
mutation: 'replace',
|
||||
sourceKey,
|
||||
}
|
||||
}
|
||||
else if (envelope.strategy === CONTEXT_UPDATE_APPEND_SELF) {
|
||||
currentActiveContexts.get(sourceKey)?.push(safeEnvelopeToStore)
|
||||
result = {
|
||||
sourceKey,
|
||||
mutation: 'append',
|
||||
entryCount: currentActiveContexts.get(sourceKey)?.length ?? 0,
|
||||
mutation: 'append',
|
||||
sourceKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,10 +135,29 @@ export function createContextRegistry(options: CreateContextRegistryOptions = {}
|
||||
}
|
||||
|
||||
return {
|
||||
activeContexts: snapshot,
|
||||
contextHistory: () => structuredClone(currentContextHistory),
|
||||
ingest,
|
||||
reset,
|
||||
snapshot,
|
||||
activeContexts: snapshot,
|
||||
contextHistory: () => structuredClone(currentContextHistory),
|
||||
}
|
||||
}
|
||||
|
||||
function defaultGetSourceKey(event: EventSourcePayload, fallback = 'unknown') {
|
||||
return (
|
||||
formatMetadataSource(event.metadata?.source)
|
||||
?? event.source
|
||||
?? fallback
|
||||
)
|
||||
}
|
||||
|
||||
function formatMetadataSource(source?: MetadataEventSource) {
|
||||
if (!source)
|
||||
return undefined
|
||||
|
||||
if ('extension' in source) {
|
||||
return `${source.extension.id}:${source.id}`
|
||||
}
|
||||
|
||||
return source.id
|
||||
}
|
||||
|
||||
@@ -3,69 +3,81 @@ const TAG_CLOSE = '|>'
|
||||
const ESCAPED_TAG_OPEN = '<{\'|\'}'
|
||||
const ESCAPED_TAG_CLOSE = '{\'|\'}>'
|
||||
|
||||
interface MarkerParserOptions {
|
||||
minLiteralEmitLength?: number
|
||||
}
|
||||
|
||||
interface MarkerToken {
|
||||
type: 'literal' | 'special'
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MarkerParserOptions {
|
||||
minLiteralEmitLength?: number
|
||||
}
|
||||
|
||||
interface StreamController<T> {
|
||||
stream: ReadableStream<T>
|
||||
write: (value: T) => void
|
||||
close: () => void
|
||||
error: (err: unknown) => void
|
||||
stream: ReadableStream<T>
|
||||
write: (value: T) => void
|
||||
}
|
||||
|
||||
function createPushStream<T>(): StreamController<T> {
|
||||
let closed = false
|
||||
let controller: ReadableStreamDefaultController<T> | null = null
|
||||
/**
|
||||
* Creates a streaming parser for LLM responses with AIRI special markers.
|
||||
*
|
||||
* Use when:
|
||||
* - Handling streamed model output that may contain `<|...|>` markers.
|
||||
* - Literal text and special marker tokens need to be emitted separately.
|
||||
*
|
||||
* Expects:
|
||||
* - Callers feed chunks in order and call `end()` once the model stream ends.
|
||||
*
|
||||
* Returns:
|
||||
* - A parser with `consume()` and `end()` methods.
|
||||
*/
|
||||
export function useLlmmarkerParser(options: {
|
||||
/**
|
||||
* The minimum length of text required to emit a literal part.
|
||||
* Useful for avoiding emitting literal parts too fast.
|
||||
*/
|
||||
minLiteralEmitLength?: number
|
||||
/**
|
||||
* Called when parsing ends with the full accumulated text.
|
||||
* Useful for final processing like categorization or filtering.
|
||||
*/
|
||||
onEnd?: (fullText: string) => Promise<void> | void
|
||||
onLiteral?: (literal: string) => Promise<void> | void
|
||||
onSpecial?: (special: string) => Promise<void> | void
|
||||
}) {
|
||||
let fullText = ''
|
||||
const { close, stream, write } = createPushStream<string>()
|
||||
|
||||
const stream = new ReadableStream<T>({
|
||||
start(ctrl) {
|
||||
controller = ctrl
|
||||
},
|
||||
cancel() {
|
||||
closed = true
|
||||
},
|
||||
const markerStream = createLlmMarkerStream(stream, { minLiteralEmitLength: options.minLiteralEmitLength })
|
||||
|
||||
const processing = readStream(markerStream, async (token) => {
|
||||
if (token.type === 'literal')
|
||||
await options.onLiteral?.(token.value)
|
||||
if (token.type === 'special')
|
||||
await options.onSpecial?.(token.value)
|
||||
})
|
||||
|
||||
return {
|
||||
stream,
|
||||
write(value) {
|
||||
if (!controller || closed)
|
||||
return
|
||||
controller.enqueue(value)
|
||||
/**
|
||||
* Consumes a chunk of text from the stream.
|
||||
*
|
||||
* @param textPart The chunk of text to consume.
|
||||
*/
|
||||
async consume(textPart: string) {
|
||||
fullText += textPart
|
||||
write(textPart)
|
||||
},
|
||||
close() {
|
||||
if (!controller || closed)
|
||||
return
|
||||
closed = true
|
||||
controller.close()
|
||||
},
|
||||
error(err) {
|
||||
if (!controller || closed)
|
||||
return
|
||||
closed = true
|
||||
controller.error(err)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readStream<T>(stream: ReadableStream<T>, handler: (value: T) => Promise<void> | void) {
|
||||
const reader = stream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
await handler(value as T)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
reader.releaseLock()
|
||||
/**
|
||||
* Finalizes the parsing process.
|
||||
* Any remaining content in the buffer is flushed as a final literal part.
|
||||
*/
|
||||
async end() {
|
||||
close()
|
||||
await processing
|
||||
await options.onEnd?.(fullText)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +136,7 @@ function createLlmMarkerParser(options?: MarkerParserOptions) {
|
||||
}
|
||||
|
||||
function createLlmMarkerStream(input: ReadableStream<string>, options?: MarkerParserOptions) {
|
||||
const { stream, write, close, error } = createPushStream<MarkerToken>()
|
||||
const { close, error, stream, write } = createPushStream<MarkerToken>()
|
||||
const parser = createLlmMarkerParser(options)
|
||||
|
||||
void readStream(input, async (chunk) => {
|
||||
@@ -155,64 +167,52 @@ function createLlmMarkerStream(input: ReadableStream<string>, options?: MarkerPa
|
||||
return stream
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a streaming parser for LLM responses with AIRI special markers.
|
||||
*
|
||||
* Use when:
|
||||
* - Handling streamed model output that may contain `<|...|>` markers.
|
||||
* - Literal text and special marker tokens need to be emitted separately.
|
||||
*
|
||||
* Expects:
|
||||
* - Callers feed chunks in order and call `end()` once the model stream ends.
|
||||
*
|
||||
* Returns:
|
||||
* - A parser with `consume()` and `end()` methods.
|
||||
*/
|
||||
export function useLlmmarkerParser(options: {
|
||||
onLiteral?: (literal: string) => void | Promise<void>
|
||||
onSpecial?: (special: string) => void | Promise<void>
|
||||
/**
|
||||
* Called when parsing ends with the full accumulated text.
|
||||
* Useful for final processing like categorization or filtering.
|
||||
*/
|
||||
onEnd?: (fullText: string) => void | Promise<void>
|
||||
/**
|
||||
* The minimum length of text required to emit a literal part.
|
||||
* Useful for avoiding emitting literal parts too fast.
|
||||
*/
|
||||
minLiteralEmitLength?: number
|
||||
}) {
|
||||
let fullText = ''
|
||||
const { stream, write, close } = createPushStream<string>()
|
||||
function createPushStream<T>(): StreamController<T> {
|
||||
let closed = false
|
||||
let controller: null | ReadableStreamDefaultController<T> = null
|
||||
|
||||
const markerStream = createLlmMarkerStream(stream, { minLiteralEmitLength: options.minLiteralEmitLength })
|
||||
|
||||
const processing = readStream(markerStream, async (token) => {
|
||||
if (token.type === 'literal')
|
||||
await options.onLiteral?.(token.value)
|
||||
if (token.type === 'special')
|
||||
await options.onSpecial?.(token.value)
|
||||
const stream = new ReadableStream<T>({
|
||||
cancel() {
|
||||
closed = true
|
||||
},
|
||||
start(ctrl) {
|
||||
controller = ctrl
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
/**
|
||||
* Consumes a chunk of text from the stream.
|
||||
*
|
||||
* @param textPart The chunk of text to consume.
|
||||
*/
|
||||
async consume(textPart: string) {
|
||||
fullText += textPart
|
||||
write(textPart)
|
||||
close() {
|
||||
if (!controller || closed)
|
||||
return
|
||||
closed = true
|
||||
controller.close()
|
||||
},
|
||||
|
||||
/**
|
||||
* Finalizes the parsing process.
|
||||
* Any remaining content in the buffer is flushed as a final literal part.
|
||||
*/
|
||||
async end() {
|
||||
close()
|
||||
await processing
|
||||
await options.onEnd?.(fullText)
|
||||
error(err) {
|
||||
if (!controller || closed)
|
||||
return
|
||||
closed = true
|
||||
controller.error(err)
|
||||
},
|
||||
stream,
|
||||
write(value) {
|
||||
if (!controller || closed)
|
||||
return
|
||||
controller.enqueue(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readStream<T>(stream: ReadableStream<T>, handler: (value: T) => Promise<void> | void) {
|
||||
const reader = stream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
await handler(value as T)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,14 @@ const provider = {
|
||||
|
||||
function createMockStreamResult(
|
||||
steps: Promise<unknown[]> = Promise.resolve([]),
|
||||
totalUsage: Promise<{ inputTokens: number, outputTokens: number, totalTokens: number } | undefined> = Promise.resolve(undefined),
|
||||
totalUsage: Promise<undefined | { inputTokens: number, outputTokens: number, totalTokens: number }> = Promise.resolve(undefined),
|
||||
messages: Promise<Message[]> = Promise.resolve([]),
|
||||
) {
|
||||
return {
|
||||
steps,
|
||||
messages,
|
||||
usage: Promise.resolve(undefined),
|
||||
steps,
|
||||
totalUsage,
|
||||
usage: Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,20 +48,20 @@ describe('streamFrom tool errors', () => {
|
||||
it('emits the final xsAI messages after all tool rounds finish', async () => {
|
||||
const onMessages = vi.fn()
|
||||
const finalMessages: Message[] = [
|
||||
{ role: 'user', content: 'Check the weather.' },
|
||||
{ content: 'Check the weather.', role: 'user' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: { arguments: '{}', name: 'weather' },
|
||||
id: 'call-weather',
|
||||
type: 'function',
|
||||
function: { name: 'weather', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call-weather', content: 'sunny' },
|
||||
{ role: 'assistant', content: 'The weather is sunny.' },
|
||||
{ content: 'sunny', role: 'tool', tool_call_id: 'call-weather' },
|
||||
{ content: 'The weather is sunny.', role: 'assistant' },
|
||||
]
|
||||
streamTextMock.mockReturnValueOnce(createMockStreamResult(
|
||||
Promise.resolve([]),
|
||||
@@ -70,9 +70,9 @@ describe('streamFrom tool errors', () => {
|
||||
))
|
||||
|
||||
await streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: finalMessages.slice(0, 1),
|
||||
model: 'model-a',
|
||||
options: { onMessages },
|
||||
})
|
||||
|
||||
@@ -101,14 +101,14 @@ describe('streamFrom tool errors', () => {
|
||||
// We mark steps settled before awaiting the final transcript, while still
|
||||
// treating transcript persistence failures as real stream failures.
|
||||
const pending = streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'hello' }] as Message[],
|
||||
messages: [{ content: 'hello', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(onEvent).toBeTypeOf('function'))
|
||||
await Promise.resolve()
|
||||
await onEvent!({ type: 'error', message: 'stream failed', cause: new Error('stream failed') })
|
||||
await onEvent!({ cause: new Error('stream failed'), message: 'stream failed', type: 'error' })
|
||||
resolveMessages?.([])
|
||||
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
@@ -122,9 +122,9 @@ describe('streamFrom tool errors', () => {
|
||||
))
|
||||
|
||||
await streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'hello' }] as Message[],
|
||||
messages: [{ content: 'hello', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
options: { onUsage },
|
||||
})
|
||||
|
||||
@@ -135,8 +135,8 @@ describe('streamFrom tool errors', () => {
|
||||
expect(onUsage).toHaveBeenCalledWith({
|
||||
inputTokens: 12,
|
||||
outputTokens: 8,
|
||||
totalTokens: 20,
|
||||
source: 'reported',
|
||||
totalTokens: 20,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,9 +145,9 @@ describe('streamFrom tool errors', () => {
|
||||
streamTextMock.mockReturnValueOnce(createMockStreamResult())
|
||||
|
||||
await streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'hello' }] as Message[],
|
||||
messages: [{ content: 'hello', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
options: { onUsage },
|
||||
})
|
||||
|
||||
@@ -162,9 +162,9 @@ describe('streamFrom tool errors', () => {
|
||||
))
|
||||
|
||||
await streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'hello' }] as Message[],
|
||||
messages: [{ content: 'hello', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
options: { onUsage },
|
||||
})
|
||||
|
||||
@@ -187,9 +187,9 @@ describe('streamFrom tool errors', () => {
|
||||
|
||||
try {
|
||||
await expect(streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'hello' }] as Message[],
|
||||
messages: [{ content: 'hello', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
})).rejects.toThrow('provider stream failed')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(unhandledRejections).toEqual([])
|
||||
@@ -203,9 +203,9 @@ describe('streamFrom tool errors', () => {
|
||||
streamTextMock.mockReturnValueOnce(createMockStreamResult())
|
||||
|
||||
await expect(streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'hello' }] as Message[],
|
||||
messages: [{ content: 'hello', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
options: {
|
||||
onUsage: () => {
|
||||
throw new Error('analytics unavailable')
|
||||
@@ -218,15 +218,15 @@ describe('streamFrom tool errors', () => {
|
||||
let resolveSteps: ((steps: unknown[]) => void) | undefined
|
||||
const events: unknown[] = []
|
||||
const failingTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'play_chess',
|
||||
description: 'Start chess.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
execute: vi.fn(() => {
|
||||
throw new Error('Focus mode does not accept game-state mutation inputs.')
|
||||
}),
|
||||
function: {
|
||||
description: 'Start chess.',
|
||||
name: 'play_chess',
|
||||
parameters: { properties: {}, type: 'object' },
|
||||
},
|
||||
type: 'function',
|
||||
} satisfies Tool
|
||||
|
||||
streamTextMock.mockImplementationOnce((options: {
|
||||
@@ -240,14 +240,14 @@ describe('streamFrom tool errors', () => {
|
||||
|
||||
queueMicrotask(async () => {
|
||||
await options.onEvent({
|
||||
type: 'tool-result.done',
|
||||
args: {},
|
||||
isError: true,
|
||||
result: 'Tool "play_chess" execution failed: Focus mode does not accept game-state mutation inputs.',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'play_chess',
|
||||
type: 'tool-result.done',
|
||||
})
|
||||
await options.onEvent({ type: 'text.delta', delta: 'ok' })
|
||||
await options.onEvent({ delta: 'ok', type: 'text.delta' })
|
||||
resolveSteps?.([])
|
||||
})
|
||||
|
||||
@@ -255,14 +255,14 @@ describe('streamFrom tool errors', () => {
|
||||
})
|
||||
|
||||
await streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'play chess' }] as Message[],
|
||||
messages: [{ content: 'play chess', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
options: {
|
||||
tools: [failingTool],
|
||||
onStreamEvent: (event) => {
|
||||
events.push(event)
|
||||
},
|
||||
tools: [failingTool],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -271,14 +271,14 @@ describe('streamFrom tool errors', () => {
|
||||
expect(streamOptions.tools?.[0]).toBe(failingTool)
|
||||
expect(failingTool.execute).not.toHaveBeenCalled()
|
||||
expect(events).toContainEqual({
|
||||
type: 'tool-error',
|
||||
args: {},
|
||||
isError: true,
|
||||
result: 'Tool "play_chess" execution failed: Focus mode does not accept game-state mutation inputs.',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'play_chess',
|
||||
type: 'tool-error',
|
||||
})
|
||||
expect(events).toContainEqual({ type: 'text-delta', text: 'ok' })
|
||||
expect(events).toContainEqual({ text: 'ok', type: 'text-delta' })
|
||||
expect(events).toContainEqual({ type: 'finish' })
|
||||
})
|
||||
|
||||
@@ -286,9 +286,9 @@ describe('streamFrom tool errors', () => {
|
||||
streamTextMock.mockReturnValueOnce(createMockStreamResult())
|
||||
|
||||
await expect(streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'hello' }] as Message[],
|
||||
messages: [{ content: 'hello', role: 'user' }] as Message[],
|
||||
model: 'model-a',
|
||||
options: {
|
||||
onStreamEvent: async (event) => {
|
||||
if (event.type === 'finish')
|
||||
@@ -306,9 +306,9 @@ describe('sanitizeMessages', () => {
|
||||
* sanitizeMessages([{ role: 'error', content: 'Remote sent 400' }])
|
||||
* // -> [{ role: 'user', content: 'User encountered error: Remote sent 400' }]
|
||||
*/
|
||||
const out = sanitizeMessages([{ role: 'error', content: 'Remote sent 400' }])
|
||||
const out = sanitizeMessages([{ content: 'Remote sent 400', role: 'error' }])
|
||||
expect(out).toEqual([
|
||||
{ role: 'user', content: 'User encountered error: Remote sent 400' },
|
||||
{ content: 'User encountered error: Remote sent 400', role: 'user' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -322,13 +322,13 @@ describe('sanitizeMessages', () => {
|
||||
* // -> [{ role: 'user', content: 'hi there' }]
|
||||
*/
|
||||
const out = sanitizeMessages([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'text', text: ' there' },
|
||||
{ text: 'hi', type: 'text' },
|
||||
{ text: ' there', type: 'text' },
|
||||
],
|
||||
role: 'user',
|
||||
}])
|
||||
expect(out).toEqual([{ role: 'user', content: 'hi there' }])
|
||||
expect(out).toEqual([{ content: 'hi there', role: 'user' }])
|
||||
})
|
||||
|
||||
it('preserves multimodal arrays when supportsContentArray is true (default)', () => {
|
||||
@@ -338,11 +338,11 @@ describe('sanitizeMessages', () => {
|
||||
* // -> unchanged: image_url part stays so vision-capable providers receive the image
|
||||
*/
|
||||
const message = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'see this' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' } },
|
||||
{ text: 'see this', type: 'text' },
|
||||
{ image_url: { url: 'data:image/png;base64,AAA' }, type: 'image_url' },
|
||||
],
|
||||
role: 'user',
|
||||
}
|
||||
const out = sanitizeMessages([message])
|
||||
expect(out[0]).toEqual(message)
|
||||
@@ -372,14 +372,14 @@ describe('sanitizeMessages', () => {
|
||||
*/
|
||||
const out = sanitizeMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' } },
|
||||
{ text: 'hi', type: 'text' },
|
||||
{ image_url: { url: 'data:image/png;base64,AAA' }, type: 'image_url' },
|
||||
],
|
||||
role: 'user',
|
||||
},
|
||||
], false)
|
||||
expect(out).toEqual([{ role: 'user', content: 'hi' }])
|
||||
expect(out).toEqual([{ content: 'hi', role: 'user' }])
|
||||
})
|
||||
|
||||
it('issue #1500: drops audio/file parts when supportsContentArray=false', () => {
|
||||
@@ -390,22 +390,22 @@ describe('sanitizeMessages', () => {
|
||||
*/
|
||||
const out = sanitizeMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'q' },
|
||||
{ type: 'input_audio', input_audio: { data: 'AAA', format: 'wav' } },
|
||||
{ type: 'file', file: { file_id: 'f_1' } },
|
||||
{ text: 'q', type: 'text' },
|
||||
{ input_audio: { data: 'AAA', format: 'wav' }, type: 'input_audio' },
|
||||
{ file: { file_id: 'f_1' }, type: 'file' },
|
||||
],
|
||||
role: 'user',
|
||||
},
|
||||
], false)
|
||||
expect(out).toEqual([{ role: 'user', content: 'q' }])
|
||||
expect(out).toEqual([{ content: 'q', role: 'user' }])
|
||||
})
|
||||
|
||||
it('passes string content through untouched regardless of the flag', () => {
|
||||
expect(sanitizeMessages([{ role: 'user', content: 'plain' }], true))
|
||||
.toEqual([{ role: 'user', content: 'plain' }])
|
||||
expect(sanitizeMessages([{ role: 'user', content: 'plain' }], false))
|
||||
.toEqual([{ role: 'user', content: 'plain' }])
|
||||
expect(sanitizeMessages([{ content: 'plain', role: 'user' }], true))
|
||||
.toEqual([{ content: 'plain', role: 'user' }])
|
||||
expect(sanitizeMessages([{ content: 'plain', role: 'user' }], false))
|
||||
.toEqual([{ content: 'plain', role: 'user' }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { StreamEvent, StreamFromOptions, StreamOptions } from '../types/llm
|
||||
import { stepCountAtLeast } from '@xsai/shared-chat'
|
||||
import { streamText } from '@xsai/stream-text'
|
||||
|
||||
export function modelKey(model: string, chatProvider: ChatProvider): string {
|
||||
return `${chatProvider.chat(model).baseURL}-${model}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize chat messages so they match the wire format the active provider
|
||||
* actually accepts, flattening content-part arrays back to plain strings when
|
||||
@@ -35,8 +39,8 @@ export function sanitizeMessages(messages: unknown[], supportsContentArray: bool
|
||||
return messages.map((message: any) => {
|
||||
if (message && message.role === 'error') {
|
||||
return {
|
||||
role: 'user',
|
||||
content: `User encountered error: ${String(message.content ?? '')}`,
|
||||
role: 'user',
|
||||
} as Message
|
||||
}
|
||||
|
||||
@@ -54,7 +58,7 @@ export function sanitizeMessages(messages: unknown[], supportsContentArray: bool
|
||||
// arrays uniformly (no longer realistic for the OpenAI-compatible
|
||||
// ecosystem, so this is effectively load-bearing).
|
||||
if (message && Array.isArray(message.content)) {
|
||||
const contentParts = message.content as { type?: string, text?: string }[]
|
||||
const contentParts = message.content as { text?: string, type?: string }[]
|
||||
const hasNonTextPart = contentParts.some(part => part?.type && part.type !== 'text')
|
||||
// When the provider supports arrays, only flatten pure-text arrays so we
|
||||
// never silently drop image / audio / file parts on a vision-capable
|
||||
@@ -69,86 +73,12 @@ export function sanitizeMessages(messages: unknown[], supportsContentArray: bool
|
||||
})
|
||||
}
|
||||
|
||||
export function modelKey(model: string, chatProvider: ChatProvider): string {
|
||||
return `${chatProvider.chat(model).baseURL}-${model}`
|
||||
}
|
||||
|
||||
export function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProvider, options?: StreamOptions): boolean {
|
||||
if (options?.supportsTools !== undefined)
|
||||
return options.supportsTools
|
||||
const key = modelKey(model, chatProvider)
|
||||
return options?.toolsCompatibility?.get(key) !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether the active model+provider currently supports content-part
|
||||
* arrays. Defaults to `true` so first-time calls keep multimodal payloads;
|
||||
* flips to `false` once {@link isContentArrayRelatedError} has fired on this
|
||||
* model key and the caller has cached the degrade in
|
||||
* {@link StreamOptions.contentArrayCompatibility}.
|
||||
*/
|
||||
export function streamOptionsContentArrayCompatibilityOk(model: string, chatProvider: ChatProvider, options?: StreamOptions): boolean {
|
||||
if (options?.supportsContentArray !== undefined)
|
||||
return options.supportsContentArray
|
||||
const key = modelKey(model, chatProvider)
|
||||
return options?.contentArrayCompatibility?.get(key) !== false
|
||||
}
|
||||
|
||||
async function resolveTools(options?: StreamOptions) {
|
||||
const tools = typeof options?.tools === 'function'
|
||||
? await options.tools()
|
||||
: options?.tools
|
||||
return tools ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps xsAI stream events onto the AIRI {@link StreamEvent} contract.
|
||||
*
|
||||
* xsAI 0.5.0-beta.8 marks failed tool executions with `isError: true` on
|
||||
* `tool-result.done` instead of aborting the stream, so AIRI can distinguish
|
||||
* `tool-error` from `tool-result` directly from the event payload.
|
||||
*/
|
||||
function toAiriStreamEvent(event: Event): StreamEvent | null {
|
||||
switch (event.type) {
|
||||
case 'text.delta':
|
||||
return { type: 'text-delta', text: event.delta }
|
||||
case 'reasoning.delta':
|
||||
return { type: 'reasoning-delta', text: event.delta }
|
||||
case 'tool-call.done':
|
||||
return { ...event, type: 'tool-call' }
|
||||
case 'tool-result.done':
|
||||
if (event.isError === true)
|
||||
return { ...event, type: 'tool-error', isError: true }
|
||||
return {
|
||||
type: 'tool-result',
|
||||
toolCallId: event.toolCallId,
|
||||
result: typeof event.result === 'string' || Array.isArray(event.result)
|
||||
? event.result
|
||||
: JSON.stringify(event.result),
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
error: event.cause ?? new Error(event.message),
|
||||
}
|
||||
case 'text.start':
|
||||
case 'text.done':
|
||||
case 'reasoning.start':
|
||||
case 'reasoning.done':
|
||||
case 'step.start':
|
||||
case 'step.done':
|
||||
case 'tool-call.start':
|
||||
case 'tool-call.delta':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamFrom({
|
||||
model,
|
||||
builtinToolsResolver,
|
||||
chatProvider,
|
||||
messages,
|
||||
model,
|
||||
options,
|
||||
builtinToolsResolver,
|
||||
}: StreamFromOptions) {
|
||||
const chatConfig = chatProvider.chat(model)
|
||||
const supportsContentArray = streamOptionsContentArrayCompatibilityOk(model, chatProvider, options)
|
||||
@@ -195,13 +125,13 @@ export async function streamFrom({
|
||||
const streamResult = streamText({
|
||||
...chatConfig,
|
||||
abortSignal: options?.abortSignal,
|
||||
messages: sanitized,
|
||||
headers: options?.headers,
|
||||
streamOptions: { includeUsage: true },
|
||||
stopWhen: stepCountAtLeast(10),
|
||||
tools,
|
||||
toolChoice: options?.toolChoice,
|
||||
messages: sanitized,
|
||||
onEvent,
|
||||
stopWhen: stepCountAtLeast(10),
|
||||
streamOptions: { includeUsage: true },
|
||||
toolChoice: options?.toolChoice,
|
||||
tools,
|
||||
})
|
||||
|
||||
// NOTICE: Consume underlying promises to prevent unhandled rejections from
|
||||
@@ -248,7 +178,7 @@ export async function streamFrom({
|
||||
}
|
||||
return
|
||||
}
|
||||
let usage: Usage | undefined
|
||||
let usage: undefined | Usage
|
||||
try {
|
||||
usage = await streamResult.totalUsage
|
||||
}
|
||||
@@ -294,6 +224,76 @@ export async function streamFrom({
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether the active model+provider currently supports content-part
|
||||
* arrays. Defaults to `true` so first-time calls keep multimodal payloads;
|
||||
* flips to `false` once {@link isContentArrayRelatedError} has fired on this
|
||||
* model key and the caller has cached the degrade in
|
||||
* {@link StreamOptions.contentArrayCompatibility}.
|
||||
*/
|
||||
export function streamOptionsContentArrayCompatibilityOk(model: string, chatProvider: ChatProvider, options?: StreamOptions): boolean {
|
||||
if (options?.supportsContentArray !== undefined)
|
||||
return options.supportsContentArray
|
||||
const key = modelKey(model, chatProvider)
|
||||
return options?.contentArrayCompatibility?.get(key) !== false
|
||||
}
|
||||
|
||||
export function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProvider, options?: StreamOptions): boolean {
|
||||
if (options?.supportsTools !== undefined)
|
||||
return options.supportsTools
|
||||
const key = modelKey(model, chatProvider)
|
||||
return options?.toolsCompatibility?.get(key) !== false
|
||||
}
|
||||
|
||||
async function resolveTools(options?: StreamOptions) {
|
||||
const tools = typeof options?.tools === 'function'
|
||||
? await options.tools()
|
||||
: options?.tools
|
||||
return tools ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps xsAI stream events onto the AIRI {@link StreamEvent} contract.
|
||||
*
|
||||
* xsAI 0.5.0-beta.8 marks failed tool executions with `isError: true` on
|
||||
* `tool-result.done` instead of aborting the stream, so AIRI can distinguish
|
||||
* `tool-error` from `tool-result` directly from the event payload.
|
||||
*/
|
||||
function toAiriStreamEvent(event: Event): null | StreamEvent {
|
||||
switch (event.type) {
|
||||
case 'error':
|
||||
return {
|
||||
error: event.cause ?? new Error(event.message),
|
||||
type: 'error',
|
||||
}
|
||||
case 'reasoning.delta':
|
||||
return { text: event.delta, type: 'reasoning-delta' }
|
||||
case 'reasoning.done':
|
||||
case 'reasoning.start':
|
||||
case 'step.done':
|
||||
case 'step.start':
|
||||
case 'text.done':
|
||||
case 'text.start':
|
||||
case 'tool-call.delta':
|
||||
case 'tool-call.start':
|
||||
return null
|
||||
case 'text.delta':
|
||||
return { text: event.delta, type: 'text-delta' }
|
||||
case 'tool-call.done':
|
||||
return { ...event, type: 'tool-call' }
|
||||
case 'tool-result.done':
|
||||
if (event.isError === true)
|
||||
return { ...event, isError: true, type: 'tool-error' }
|
||||
return {
|
||||
result: typeof event.result === 'string' || Array.isArray(event.result)
|
||||
? event.result
|
||||
: JSON.stringify(event.result),
|
||||
toolCallId: event.toolCallId,
|
||||
type: 'tool-result',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime auto-degrade: patterns that indicate the model/provider does not support tool calling.
|
||||
const TOOLS_RELATED_ERROR_PATTERNS: RegExp[] = [
|
||||
/does not support tools/i, // Ollama
|
||||
|
||||
@@ -138,11 +138,11 @@ describe('createStreamingCategorizer', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log({
|
||||
input: text,
|
||||
segmentsFound: result.segments.length,
|
||||
tagName: result.segments[0]?.tagName,
|
||||
segmentContent: result.segments[0]?.content,
|
||||
reasoning: result.reasoning,
|
||||
segmentContent: result.segments[0]?.content,
|
||||
segmentsFound: result.segments.length,
|
||||
speech: result.speech,
|
||||
tagName: result.segments[0]?.tagName,
|
||||
})
|
||||
|
||||
// Verify tag is recognized
|
||||
@@ -176,11 +176,11 @@ describe('createStreamingCategorizer', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log({
|
||||
input: text,
|
||||
segmentsFound: result.segments.length,
|
||||
tagName: result.segments[0]?.tagName,
|
||||
segmentContent: result.segments[0]?.content,
|
||||
reasoning: result.reasoning,
|
||||
segmentContent: result.segments[0]?.content,
|
||||
segmentsFound: result.segments.length,
|
||||
speech: result.speech,
|
||||
tagName: result.segments[0]?.tagName,
|
||||
})
|
||||
|
||||
// Verify tag is recognized
|
||||
@@ -259,9 +259,9 @@ describe('createStreamingCategorizer', () => {
|
||||
})
|
||||
|
||||
it('should call onSegment callback when segments are detected', () => {
|
||||
const segments: Array<{ tagName: string, content: string }> = []
|
||||
const segments: Array<{ content: string, tagName: string }> = []
|
||||
const categorizer = createStreamingCategorizer(undefined, (segment) => {
|
||||
segments.push({ tagName: segment.tagName, content: segment.content })
|
||||
segments.push({ content: segment.content, tagName: segment.tagName })
|
||||
})
|
||||
|
||||
const text = 'Hello <reasoning>thought1</reasoning> <think>thought2</think> world!'
|
||||
|
||||
@@ -7,130 +7,30 @@ import rehypeStringify from 'rehype-stringify'
|
||||
import { unified } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
export type ResponseCategory = 'speech' | 'reasoning' | 'unknown'
|
||||
export interface CategorizedResponse {
|
||||
raw: string // Original full response
|
||||
reasoning: string // Combined reasoning/thought content
|
||||
segments: CategorizedSegment[]
|
||||
speech: string // Combined speech content (everything outside tags)
|
||||
}
|
||||
|
||||
export interface CategorizedSegment {
|
||||
category: ResponseCategory
|
||||
content: string
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
raw: string // Original tagged content including tags
|
||||
startIndex: number
|
||||
tagName: string // The actual tag name found (e.g., "think", "thought", "reasoning")
|
||||
}
|
||||
|
||||
export interface CategorizedResponse {
|
||||
segments: CategorizedSegment[]
|
||||
speech: string // Combined speech content (everything outside tags)
|
||||
reasoning: string // Combined reasoning/thought content
|
||||
raw: string // Original full response
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tag names to categories
|
||||
* All tags are treated as reasoning (filtered from TTS)
|
||||
*/
|
||||
function mapTagNameToCategory(_tagName: string): ResponseCategory {
|
||||
// All tags are reasoning - no need to distinguish tag names
|
||||
return 'reasoning'
|
||||
}
|
||||
export type ResponseCategory = 'reasoning' | 'speech' | 'unknown'
|
||||
|
||||
interface ExtractedTag {
|
||||
tagName: string
|
||||
content: string
|
||||
endIndex: number
|
||||
fullMatch: string
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all XML-like tags from a response using rehype pipeline
|
||||
* Works with any tag format: <tag>content</tag>
|
||||
* Only extracts tags that are actually complete (have closing tags in source)
|
||||
*/
|
||||
function extractAllTags(response: string): ExtractedTag[] {
|
||||
const tags: ExtractedTag[] = []
|
||||
|
||||
try {
|
||||
const tree = unified().use(rehypeParse, { fragment: true }).parse(response) as Root
|
||||
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
const position = node.position
|
||||
if (!position?.start || !position?.end)
|
||||
return
|
||||
|
||||
const startIndex = getOffsetFromPosition(response, position.start)
|
||||
const endIndex = getOffsetFromPosition(response, position.end)
|
||||
|
||||
if (startIndex === -1 || endIndex === -1)
|
||||
return
|
||||
|
||||
// Extract the actual tag content from source
|
||||
const fullMatch = response.slice(startIndex, endIndex)
|
||||
|
||||
// Only include tags that have a closing tag in the source (not auto-closed by rehype)
|
||||
// Check if the source actually contains the closing tag
|
||||
const expectedClosingTag = `</${node.tagName}>`
|
||||
if (!fullMatch.includes(expectedClosingTag)) {
|
||||
// This tag was auto-closed by rehype, so it's incomplete - skip it
|
||||
return
|
||||
}
|
||||
|
||||
tags.push({
|
||||
tagName: node.tagName,
|
||||
content: extractTextContent(node),
|
||||
fullMatch,
|
||||
startIndex,
|
||||
endIndex,
|
||||
})
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to parse response for tag extraction:', error)
|
||||
// If parsing fails, return empty array (no tags found)
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a position (line/column) to a character offset in the string
|
||||
*/
|
||||
function getOffsetFromPosition(text: string, position: Position['start']): number {
|
||||
if (!position || typeof position.line !== 'number' || typeof position.column !== 'number')
|
||||
return -1
|
||||
|
||||
const lines = text.split('\n')
|
||||
let offset = 0
|
||||
|
||||
// Sum up lengths of all lines before the target line
|
||||
for (let i = 0; i < position.line - 1 && i < lines.length; i++) {
|
||||
offset += lines[i].length + 1 // +1 for the newline character
|
||||
}
|
||||
|
||||
// Add the column offset (subtract 1 because columns are 1-indexed)
|
||||
offset += position.column - 1
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from an element node
|
||||
*/
|
||||
function extractTextContent(node: Element): string {
|
||||
const textParts: string[] = []
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'text') {
|
||||
textParts.push(child.value)
|
||||
}
|
||||
else if (child.type === 'element') {
|
||||
textParts.push(extractTextContent(child))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('')
|
||||
tagName: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,10 +47,10 @@ export function categorizeResponse(
|
||||
if (extractedTags.length === 0) {
|
||||
// No tags found, treat everything as speech
|
||||
return {
|
||||
raw: response,
|
||||
reasoning: '',
|
||||
segments: [],
|
||||
speech: response,
|
||||
reasoning: '',
|
||||
raw: response,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +58,9 @@ export function categorizeResponse(
|
||||
const segments: CategorizedSegment[] = extractedTags.map(tag => ({
|
||||
category: mapTagNameToCategory(tag.tagName),
|
||||
content: tag.content.trim(),
|
||||
startIndex: tag.startIndex,
|
||||
endIndex: tag.endIndex,
|
||||
raw: tag.fullMatch,
|
||||
startIndex: tag.startIndex,
|
||||
tagName: tag.tagName,
|
||||
}))
|
||||
|
||||
@@ -200,10 +100,10 @@ export function categorizeResponse(
|
||||
const speech = speechParts.join(' ').trim()
|
||||
|
||||
return {
|
||||
raw: response,
|
||||
reasoning,
|
||||
segments,
|
||||
speech: speech || '',
|
||||
reasoning,
|
||||
raw: response,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +121,7 @@ export function createStreamingCategorizer(
|
||||
let lastParsedLength = 0
|
||||
|
||||
// Lightweight state machine to detect tag closures without parsing entire buffer
|
||||
type TagState = 'outside' | 'in-opening-tag' | 'in-content' | 'in-closing-tag'
|
||||
type TagState = 'in-closing-tag' | 'in-content' | 'in-opening-tag' | 'outside'
|
||||
let tagState: TagState = 'outside'
|
||||
let tagStackDepth = 0
|
||||
|
||||
@@ -254,7 +154,21 @@ export function createStreamingCategorizer(
|
||||
const char = chunk[i]
|
||||
|
||||
switch (tagState) {
|
||||
case 'outside': {
|
||||
case 'in-closing-tag': {
|
||||
if (char === '>') {
|
||||
tagStackDepth--
|
||||
if (tagStackDepth === 0) {
|
||||
tagState = 'outside'
|
||||
tagJustClosed = true
|
||||
}
|
||||
else {
|
||||
tagState = 'in-content'
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'in-content': {
|
||||
if (char === '<') {
|
||||
if (i + 1 < chunk.length && chunk[i + 1] === '/') {
|
||||
tagState = 'in-closing-tag'
|
||||
@@ -275,7 +189,7 @@ export function createStreamingCategorizer(
|
||||
break
|
||||
}
|
||||
|
||||
case 'in-content': {
|
||||
case 'outside': {
|
||||
if (char === '<') {
|
||||
if (i + 1 < chunk.length && chunk[i + 1] === '/') {
|
||||
tagState = 'in-closing-tag'
|
||||
@@ -287,20 +201,6 @@ export function createStreamingCategorizer(
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'in-closing-tag': {
|
||||
if (char === '>') {
|
||||
tagStackDepth--
|
||||
if (tagStackDepth === 0) {
|
||||
tagState = 'outside'
|
||||
tagJustClosed = true
|
||||
}
|
||||
else {
|
||||
tagState = 'in-content'
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,26 +239,8 @@ export function createStreamingCategorizer(
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Checks if the current position in the stream is part of speech content
|
||||
* Returns true if the text should be sent to TTS
|
||||
*/
|
||||
isSpeechAt(position: number): boolean {
|
||||
if (!categorized || categorized.segments.length === 0) {
|
||||
// No categorization yet, assume it's speech
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if position falls within any non-speech segment
|
||||
for (const segment of categorized.segments) {
|
||||
if (position >= segment.startIndex && position < segment.endIndex) {
|
||||
// Position is within a tagged segment (thought/reasoning)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Position is not in any tagged segment, so it's speech
|
||||
return true
|
||||
end(): CategorizedResponse {
|
||||
return categorizeResponse(buffer, providerId)
|
||||
},
|
||||
/**
|
||||
* Filters text to only include speech parts
|
||||
@@ -453,14 +335,132 @@ export function createStreamingCategorizer(
|
||||
|
||||
return filtered
|
||||
},
|
||||
getCurrentPosition(): number {
|
||||
return buffer.length
|
||||
},
|
||||
end(): CategorizedResponse {
|
||||
return categorizeResponse(buffer, providerId)
|
||||
},
|
||||
getCurrent(): CategorizedResponse | null {
|
||||
return categorized
|
||||
},
|
||||
getCurrentPosition(): number {
|
||||
return buffer.length
|
||||
},
|
||||
/**
|
||||
* Checks if the current position in the stream is part of speech content
|
||||
* Returns true if the text should be sent to TTS
|
||||
*/
|
||||
isSpeechAt(position: number): boolean {
|
||||
if (!categorized || categorized.segments.length === 0) {
|
||||
// No categorization yet, assume it's speech
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if position falls within any non-speech segment
|
||||
for (const segment of categorized.segments) {
|
||||
if (position >= segment.startIndex && position < segment.endIndex) {
|
||||
// Position is within a tagged segment (thought/reasoning)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Position is not in any tagged segment, so it's speech
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all XML-like tags from a response using rehype pipeline
|
||||
* Works with any tag format: <tag>content</tag>
|
||||
* Only extracts tags that are actually complete (have closing tags in source)
|
||||
*/
|
||||
function extractAllTags(response: string): ExtractedTag[] {
|
||||
const tags: ExtractedTag[] = []
|
||||
|
||||
try {
|
||||
const tree = unified().use(rehypeParse, { fragment: true }).parse(response) as Root
|
||||
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
const position = node.position
|
||||
if (!position?.start || !position?.end)
|
||||
return
|
||||
|
||||
const startIndex = getOffsetFromPosition(response, position.start)
|
||||
const endIndex = getOffsetFromPosition(response, position.end)
|
||||
|
||||
if (startIndex === -1 || endIndex === -1)
|
||||
return
|
||||
|
||||
// Extract the actual tag content from source
|
||||
const fullMatch = response.slice(startIndex, endIndex)
|
||||
|
||||
// Only include tags that have a closing tag in the source (not auto-closed by rehype)
|
||||
// Check if the source actually contains the closing tag
|
||||
const expectedClosingTag = `</${node.tagName}>`
|
||||
if (!fullMatch.includes(expectedClosingTag)) {
|
||||
// This tag was auto-closed by rehype, so it's incomplete - skip it
|
||||
return
|
||||
}
|
||||
|
||||
tags.push({
|
||||
content: extractTextContent(node),
|
||||
endIndex,
|
||||
fullMatch,
|
||||
startIndex,
|
||||
tagName: node.tagName,
|
||||
})
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to parse response for tag extraction:', error)
|
||||
// If parsing fails, return empty array (no tags found)
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from an element node
|
||||
*/
|
||||
function extractTextContent(node: Element): string {
|
||||
const textParts: string[] = []
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'text') {
|
||||
textParts.push(child.value)
|
||||
}
|
||||
else if (child.type === 'element') {
|
||||
textParts.push(extractTextContent(child))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a position (line/column) to a character offset in the string
|
||||
*/
|
||||
function getOffsetFromPosition(text: string, position: Position['start']): number {
|
||||
if (!position || typeof position.line !== 'number' || typeof position.column !== 'number')
|
||||
return -1
|
||||
|
||||
const lines = text.split('\n')
|
||||
let offset = 0
|
||||
|
||||
// Sum up lengths of all lines before the target line
|
||||
for (let i = 0; i < position.line - 1 && i < lines.length; i++) {
|
||||
offset += lines[i].length + 1 // +1 for the newline character
|
||||
}
|
||||
|
||||
// Add the column offset (subtract 1 because columns are 1-indexed)
|
||||
offset += position.column - 1
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tag names to categories
|
||||
* All tags are treated as reasoning (filtered from TTS)
|
||||
*/
|
||||
function mapTagNameToCategory(_tagName: string): ResponseCategory {
|
||||
// All tags are reasoning - no need to distinguish tag names
|
||||
return 'reasoning'
|
||||
}
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
import type { ChatHistoryItem } from '../types/chat'
|
||||
|
||||
function extractMessageContent(message: ChatHistoryItem) {
|
||||
if (typeof message.content === 'string')
|
||||
return message.content
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content.map((part) => {
|
||||
if (typeof part === 'string')
|
||||
return part
|
||||
if (part && typeof part === 'object' && 'text' in part)
|
||||
return String(part.text ?? '')
|
||||
return ''
|
||||
}).join('')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getMessageFingerprint(message: ChatHistoryItem) {
|
||||
return [
|
||||
message.id ?? '',
|
||||
message.role,
|
||||
message.createdAt ?? '',
|
||||
extractMessageContent(message),
|
||||
].join('\u001F')
|
||||
}
|
||||
|
||||
export function mergeLoadedSessionMessages(storedMessages: ChatHistoryItem[], currentMessages: ChatHistoryItem[]) {
|
||||
if (currentMessages.length === 0)
|
||||
return storedMessages
|
||||
@@ -57,3 +31,29 @@ export function mergeLoadedSessionMessages(storedMessages: ChatHistoryItem[], cu
|
||||
|
||||
return [...storedMessages, ...extraMessages]
|
||||
}
|
||||
|
||||
function extractMessageContent(message: ChatHistoryItem) {
|
||||
if (typeof message.content === 'string')
|
||||
return message.content
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content.map((part) => {
|
||||
if (typeof part === 'string')
|
||||
return part
|
||||
if (part && typeof part === 'object' && 'text' in part)
|
||||
return String(part.text ?? '')
|
||||
return ''
|
||||
}).join('')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getMessageFingerprint(message: ChatHistoryItem) {
|
||||
return [
|
||||
message.id ?? '',
|
||||
message.role,
|
||||
message.createdAt ?? '',
|
||||
extractMessageContent(message),
|
||||
].join('\u001F')
|
||||
}
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
import type { ContextUpdate, MetadataEventSource, WebSocketEventInputs } from '@proj-airi/server-shared/types'
|
||||
import type { AssistantMessage, CommonContentPart, CompletionToolCall, Message, SystemMessage, ToolMessage, UserMessage } from '@xsai/shared-chat'
|
||||
|
||||
export interface ChatSlicesText {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ChatSlicesToolCall {
|
||||
type: 'tool-call'
|
||||
toolCall: CompletionToolCall
|
||||
}
|
||||
|
||||
export interface ChatSlicesToolCallResult {
|
||||
type: 'tool-call-result'
|
||||
id: string
|
||||
isError?: boolean
|
||||
result?: string | CommonContentPart[]
|
||||
}
|
||||
|
||||
export type ChatSlices = ChatSlicesText | ChatSlicesToolCall | ChatSlicesToolCallResult
|
||||
|
||||
export interface ChatAssistantMessage extends AssistantMessage {
|
||||
slices: ChatSlices[]
|
||||
tool_results: {
|
||||
id: string
|
||||
isError?: boolean
|
||||
result?: string | CommonContentPart[]
|
||||
}[]
|
||||
categorization?: {
|
||||
reasoning: string
|
||||
speech: string
|
||||
}
|
||||
/**
|
||||
* Exact provider messages that xsAI added for this assistant turn.
|
||||
*
|
||||
@@ -35,29 +14,12 @@ export interface ChatAssistantMessage extends AssistantMessage {
|
||||
* protocol order for the next provider request.
|
||||
*/
|
||||
providerTranscript?: Message[]
|
||||
categorization?: {
|
||||
speech: string
|
||||
reasoning: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatMessage = ChatAssistantMessage | SystemMessage | ToolMessage | UserMessage
|
||||
|
||||
/** Identifies one model-facing tool without storing its runtime executor. */
|
||||
export interface ChatToolReference {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
role: 'error'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ContextMessage extends ContextUpdate<Record<string, unknown>, unknown> {
|
||||
metadata?: {
|
||||
source: MetadataEventSource
|
||||
}
|
||||
createdAt: number
|
||||
slices: ChatSlices[]
|
||||
tool_results: {
|
||||
id: string
|
||||
isError?: boolean
|
||||
result?: CommonContentPart[] | string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type ChatHistoryItem = (ChatMessage | ErrorMessage) & {
|
||||
@@ -68,24 +30,62 @@ export type ChatHistoryItem = (ChatMessage | ErrorMessage) & {
|
||||
tools?: ChatToolReference[]
|
||||
}
|
||||
|
||||
export interface ChatStreamEventContext {
|
||||
/** Stable correlation id shared by every hook emitted for one user turn. */
|
||||
turnId: string
|
||||
message: ChatHistoryItem
|
||||
contexts: Record<string, ContextMessage[]>
|
||||
composedMessage: Array<Message>
|
||||
input?: WebSocketEventInputs
|
||||
export type ChatMessage = ChatAssistantMessage | SystemMessage | ToolMessage | UserMessage
|
||||
|
||||
export type ChatSlices = ChatSlicesText | ChatSlicesToolCall | ChatSlicesToolCallResult
|
||||
|
||||
export interface ChatSlicesText {
|
||||
text: string
|
||||
type: 'text'
|
||||
}
|
||||
|
||||
export interface ChatSlicesToolCall {
|
||||
toolCall: CompletionToolCall
|
||||
type: 'tool-call'
|
||||
}
|
||||
|
||||
export interface ChatSlicesToolCallResult {
|
||||
id: string
|
||||
isError?: boolean
|
||||
result?: CommonContentPart[] | string
|
||||
type: 'tool-call-result'
|
||||
}
|
||||
|
||||
export type ChatStreamEvent
|
||||
= | { type: 'before-compose', message: string, sessionId: string, context: Omit<ChatStreamEventContext, 'composedMessage'> }
|
||||
| { type: 'after-compose', message: string, sessionId: string, context: ChatStreamEventContext }
|
||||
| { type: 'before-send', message: string, sessionId: string, context: ChatStreamEventContext }
|
||||
| { type: 'after-send', message: string, sessionId: string, context: ChatStreamEventContext }
|
||||
| { type: 'token-literal', literal: string, sessionId: string, context: ChatStreamEventContext }
|
||||
| { type: 'token-special', special: string, sessionId: string, context: ChatStreamEventContext }
|
||||
| { type: 'stream-end', sessionId: string, context: ChatStreamEventContext }
|
||||
| { type: 'assistant-end', message: string, sessionId: string, context: ChatStreamEventContext }
|
||||
| { type: 'assistant-message', message: ChatAssistantMessage, sessionId: string, messageText: string, context: ChatStreamEventContext }
|
||||
= | { context: ChatStreamEventContext, literal: string, sessionId: string, type: 'token-literal' }
|
||||
| { context: ChatStreamEventContext, message: ChatAssistantMessage, messageText: string, sessionId: string, type: 'assistant-message' }
|
||||
| { context: ChatStreamEventContext, message: string, sessionId: string, type: 'after-compose' }
|
||||
| { context: ChatStreamEventContext, message: string, sessionId: string, type: 'after-send' }
|
||||
| { context: ChatStreamEventContext, message: string, sessionId: string, type: 'assistant-end' }
|
||||
| { context: ChatStreamEventContext, message: string, sessionId: string, type: 'before-send' }
|
||||
| { context: ChatStreamEventContext, sessionId: string, special: string, type: 'token-special' }
|
||||
| { context: ChatStreamEventContext, sessionId: string, type: 'stream-end' }
|
||||
| { context: Omit<ChatStreamEventContext, 'composedMessage'>, message: string, sessionId: string, type: 'before-compose' }
|
||||
|
||||
export interface ChatStreamEventContext {
|
||||
composedMessage: Array<Message>
|
||||
contexts: Record<string, ContextMessage[]>
|
||||
input?: WebSocketEventInputs
|
||||
message: ChatHistoryItem
|
||||
/** Stable correlation id shared by every hook emitted for one user turn. */
|
||||
turnId: string
|
||||
}
|
||||
|
||||
/** Identifies one model-facing tool without storing its runtime executor. */
|
||||
export interface ChatToolReference {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ContextMessage extends ContextUpdate<Record<string, unknown>, unknown> {
|
||||
createdAt: number
|
||||
metadata?: {
|
||||
source: MetadataEventSource
|
||||
}
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
content: string
|
||||
role: 'error'
|
||||
}
|
||||
|
||||
export type StreamingAssistantMessage = ChatAssistantMessage & { context?: ContextMessage } & { createdAt?: number, id?: string }
|
||||
|
||||
@@ -1,45 +1,38 @@
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { CommonContentPart, CompletionToolCall, CompletionToolResult, Message, Tool, ToolChoice } from '@xsai/shared-chat'
|
||||
|
||||
/** Describes whether generation usage came from the provider or a local fallback. */
|
||||
export type LlmUsageSource = 'reported' | 'estimated' | 'unavailable'
|
||||
export type BuiltinToolsResolver = (model: string, chatProvider: ChatProvider) => Promise<Tool[]>
|
||||
|
||||
/** Provider-safe token usage emitted after one complete streamed generation. */
|
||||
export interface LlmUsage {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
totalTokens?: number
|
||||
source: LlmUsageSource
|
||||
totalTokens?: number
|
||||
}
|
||||
|
||||
/** Describes whether generation usage came from the provider or a local fallback. */
|
||||
export type LlmUsageSource = 'estimated' | 'reported' | 'unavailable'
|
||||
|
||||
export type StreamEvent
|
||||
= | { type: 'text-delta', text: string }
|
||||
| { type: 'reasoning-delta', text: string }
|
||||
| ({ type: 'finish' } & any)
|
||||
| ({ type: 'tool-call' } & CompletionToolCall)
|
||||
| (CompletionToolResult & { type: 'tool-error', isError: true })
|
||||
| { type: 'tool-result', toolCallId: string, result?: string | CommonContentPart[] }
|
||||
| { type: 'error', error: any }
|
||||
= | (any & { type: 'finish' })
|
||||
| (CompletionToolCall & { type: 'tool-call' })
|
||||
| (CompletionToolResult & { isError: true, type: 'tool-error' })
|
||||
| { error: any, type: 'error' }
|
||||
| { result?: CommonContentPart[] | string, toolCallId: string, type: 'tool-result' }
|
||||
| { text: string, type: 'reasoning-delta' }
|
||||
| { text: string, type: 'text-delta' }
|
||||
|
||||
export interface StreamFromOptions {
|
||||
builtinToolsResolver?: BuiltinToolsResolver
|
||||
chatProvider: ChatProvider
|
||||
messages: Message[]
|
||||
model: string
|
||||
options?: StreamOptions
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
abortSignal?: AbortSignal
|
||||
headers?: Record<string, string>
|
||||
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
|
||||
/** Called once with the final xsAI message list after all tool rounds finish. */
|
||||
onMessages?: (messages: Message[]) => void | Promise<void>
|
||||
/** Called once after the full stream, including tool rounds, has settled. */
|
||||
onUsage?: (usage: LlmUsage) => void | Promise<void>
|
||||
/** Internal correlation kept out of the provider request body. */
|
||||
requestCorrelation?: {
|
||||
conversationId: string
|
||||
roundId: string
|
||||
}
|
||||
toolsCompatibility?: Map<string, boolean>
|
||||
supportsTools?: boolean
|
||||
waitForTools?: boolean
|
||||
/** Provider tool-selection directive for one request. */
|
||||
toolChoice?: ToolChoice
|
||||
tools?: Tool[] | (() => Promise<Tool[] | undefined>)
|
||||
/**
|
||||
* Per-model runtime cache of whether the provider accepts content-part arrays
|
||||
* (e.g. `[{type:'text',...},{type:'image_url',...}]`) for `messages[].content`.
|
||||
@@ -56,15 +49,22 @@ export interface StreamOptions {
|
||||
* See: https://github.com/moeru-ai/airi/issues/1500
|
||||
*/
|
||||
contentArrayCompatibility?: Map<string, boolean>
|
||||
headers?: Record<string, string>
|
||||
/** Called once with the final xsAI message list after all tool rounds finish. */
|
||||
onMessages?: (messages: Message[]) => Promise<void> | void
|
||||
onStreamEvent?: (event: StreamEvent) => Promise<void> | void
|
||||
/** Called once after the full stream, including tool rounds, has settled. */
|
||||
onUsage?: (usage: LlmUsage) => Promise<void> | void
|
||||
/** Internal correlation kept out of the provider request body. */
|
||||
requestCorrelation?: {
|
||||
conversationId: string
|
||||
roundId: string
|
||||
}
|
||||
supportsContentArray?: boolean
|
||||
}
|
||||
|
||||
export type BuiltinToolsResolver = (model: string, chatProvider: ChatProvider) => Promise<Tool[]>
|
||||
|
||||
export interface StreamFromOptions {
|
||||
model: string
|
||||
chatProvider: ChatProvider
|
||||
messages: Message[]
|
||||
options?: StreamOptions
|
||||
builtinToolsResolver?: BuiltinToolsResolver
|
||||
supportsTools?: boolean
|
||||
/** Provider tool-selection directive for one request. */
|
||||
toolChoice?: ToolChoice
|
||||
tools?: (() => Promise<Tool[] | undefined>) | Tool[]
|
||||
toolsCompatibility?: Map<string, boolean>
|
||||
waitForTools?: boolean
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
dts: true,
|
||||
entry: [
|
||||
'src/index.ts',
|
||||
'src/agents/spark-notify/index.ts',
|
||||
],
|
||||
dts: true,
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ const defaultModel = loadedEnv.OPENAI_MODEL ?? loadedEnv.OPENAI_CHAT_MODEL ?? 'o
|
||||
* Vieval config for the core-agent runtime competition.
|
||||
*/
|
||||
const coreAgentVievalConfig = defineConfig({
|
||||
env: loadedEnv,
|
||||
plugins: [
|
||||
ChatProviders({
|
||||
providers: [
|
||||
@@ -31,19 +32,18 @@ const coreAgentVievalConfig = defineConfig({
|
||||
models: [
|
||||
chatModelFrom({
|
||||
aliases: ['default', 'competition'],
|
||||
provider: 'openrouter-provider',
|
||||
model: defaultModel,
|
||||
provider: 'openrouter-provider',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
env: loadedEnv,
|
||||
projects: [
|
||||
{
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
include: ['evals/round-3-primary-control/**/*.eval.ts'],
|
||||
name: 'round-3-primary-control',
|
||||
root: '.',
|
||||
include: ['evals/round-3-primary-control/**/*.eval.ts'],
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
runMatrix: {
|
||||
override: {
|
||||
model: [defaultModel],
|
||||
@@ -51,10 +51,10 @@ const coreAgentVievalConfig = defineConfig({
|
||||
},
|
||||
},
|
||||
{
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
include: ['evals/round-3-takeover-control/**/*.eval.ts'],
|
||||
name: 'round-3-takeover-control',
|
||||
root: '.',
|
||||
include: ['evals/round-3-takeover-control/**/*.eval.ts'],
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
runMatrix: {
|
||||
override: {
|
||||
model: [defaultModel],
|
||||
@@ -62,10 +62,10 @@ const coreAgentVievalConfig = defineConfig({
|
||||
},
|
||||
},
|
||||
{
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
include: ['evals/round-3-sidecar-control/**/*.eval.ts'],
|
||||
name: 'round-3-sidecar-control',
|
||||
root: '.',
|
||||
include: ['evals/round-3-sidecar-control/**/*.eval.ts'],
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
runMatrix: {
|
||||
override: {
|
||||
model: [defaultModel],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: '@proj-airi/core-agent',
|
||||
include: ['src/**/*.test.ts'],
|
||||
name: '@proj-airi/core-agent',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user