style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
@@ -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[]>
}
+42 -42
View File
@@ -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,
+68 -68
View File
@@ -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',
},
],
})
+191 -191
View File
@@ -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,
}
})
}
+26 -26
View File
@@ -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
+212 -212
View File
@@ -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'
}
+82 -82
View File
@@ -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' }])
})
})
+84 -84
View File
@@ -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')
}
+64 -64
View File
@@ -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 }
+37 -37
View File
@@ -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 -1
View File
@@ -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,
})
+8 -8
View File
@@ -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],
+1 -1
View File
@@ -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',
},
})