refactor(core-agent): spark:notify, and related refactor

This commit is contained in:
Neko Ayaka
2026-04-28 01:20:04 +08:00
parent 5d5f739eba
commit 9b4be893ad
22 changed files with 2367 additions and 65 deletions
@@ -0,0 +1,149 @@
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
import { describe, expect, it, vi } from 'vitest'
import { setupAgentSparkNotifyHandler } from './handler'
describe('setupAgentSparkNotifyHandler', () => {
it('captures tracing artifacts for command-only spark runs', async () => {
const traces: unknown[] = []
const handler = setupAgentSparkNotifyHandler({
stream: async (_model, _provider, _messages, options) => {
const commandTool = options.tools?.find((tool: any) => tool.function?.name === 'builtIn_sparkCommand')
await commandTool?.execute({
commands: [
{
destinations: ['chess'],
interrupt: 'false',
priority: 'high',
intent: 'action',
ack: 'play e5',
guidance: null,
},
],
})
await options.onStreamEvent?.({ type: 'finish' } as any)
},
getActiveProvider: () => 'mock-provider',
getActiveModel: () => 'mock-model',
getProviderInstance: async () => ({} as any),
onReactionDelta: vi.fn(),
onReactionEnd: vi.fn(),
getSystemPrompt: () => 'system',
getProcessing: () => false,
setProcessing: vi.fn(),
getPending: () => [],
setPending: vi.fn(),
onTrace: (event: unknown) => traces.push(event),
} as any)
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'plugin:airi-plugin-game-chess',
data: {
id: 'spark-1',
eventId: 'evt-1',
kind: 'ping',
urgency: 'immediate',
headline: 'chess update',
destinations: ['character'],
},
}
const result = await handler.handle(event)
expect(result?.commands).toHaveLength(1)
expect(traces.length).toBeGreaterThan(0)
})
it('routes forceSparkCommandResponse to the model call', async () => {
const stream = vi.fn(async (_model, _provider, _messages, options) => {
await options.onStreamEvent?.({ type: 'finish' } as any)
})
const handler = setupAgentSparkNotifyHandler({
stream,
getActiveProvider: () => 'mock-provider',
getActiveModel: () => 'mock-model',
getProviderInstance: async () => ({} as any),
onReactionDelta: vi.fn(),
onReactionEnd: vi.fn(),
getSystemPrompt: () => 'system',
getProcessing: () => false,
setProcessing: vi.fn(),
getPending: () => [],
setPending: vi.fn(),
})
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'plugin:airi-plugin-game-chess',
data: {
id: 'spark-2',
eventId: 'evt-2',
kind: 'ping',
urgency: 'immediate',
headline: 'command-only update',
destinations: ['character'],
},
}
await handler.handle(event, {
forceSparkCommandResponse: true,
} as any)
const streamOptions = stream.mock.calls[0]?.[3] as { toolChoice?: unknown } | undefined
expect(streamOptions?.toolChoice).toEqual({
type: 'function',
function: {
name: 'builtIn_sparkCommand',
},
})
})
it('applies runtime-only message overrides while rendering one notify turn', async () => {
const stream = vi.fn(async (_model, _provider, messages, options) => {
expect(String(messages[0]?.content)).toContain('Extra instruction: stay concise.')
expect(String(messages[1]?.content)).toContain('"headline": "override update"')
expect(String(messages[1]?.content)).toContain('Rendered board: white to move, fen=...')
await options.onStreamEvent?.({ type: 'finish' } as any)
})
const handler = setupAgentSparkNotifyHandler({
stream,
getActiveProvider: () => 'mock-provider',
getActiveModel: () => 'mock-model',
getProviderInstance: async () => ({} as any),
onReactionDelta: vi.fn(),
onReactionEnd: vi.fn(),
getSystemPrompt: () => 'system',
getProcessing: () => false,
setProcessing: vi.fn(),
getPending: () => [],
setPending: vi.fn(),
})
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'plugin:airi-plugin-game-chess',
data: {
id: 'spark-3',
eventId: 'evt-3',
kind: 'ping',
urgency: 'immediate',
headline: 'override update',
destinations: ['character'],
},
}
await handler.handle(event, {
forceTextResponse: true,
messageOverride: {
appendSystemInstructions: ['Extra instruction: stay concise.'],
appendUserSections: ['Rendered board: white to move, fen=...'],
},
})
expect(stream).toBeCalledTimes(1)
})
})
@@ -1,52 +1,224 @@
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
import type { ChatProvider, ChatProviderWithExtraOptions, EmbedProvider, EmbedProviderWithExtraOptions, SpeechProvider, SpeechProviderWithExtraOptions, TranscriptionProvider, TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat'
import type { Message, Tool, ToolChoice } from '@xsai/shared-chat'
import type { StreamEvent } from '../../types/llm'
import type { SparkNotifyCommandDraft } from './tools'
import type {
SparkNotifyMessageOverride,
SparkNotifyResponseControl,
SparkNotifyRuntimePolicy,
SparkNotifyTracingHooks,
SparkTraceEvent,
} from './types'
import { nanoid } from 'nanoid'
import { getEventSourceKey } from './event-source'
import { createSparkNotifyTools } from './tools'
export type { SparkNotifyCommandSchema } from './schema'
export { sparkNotifyCommandSchema } from './schema'
export type { SparkNotifyCommandDraft } from './tools'
/**
* Raw spark-notify model response before runtime command event expansion.
*/
export interface SparkNotifyResponse {
/** Free-form reaction text streamed back to the caller when the model emits text output. */
reaction?: string
/** Command drafts collected from `builtIn_sparkCommand` tool calls before runtime event expansion. */
commands?: SparkNotifyCommandDraft[]
}
/**
* Final command event emitted by the notify runtime.
*/
export interface SparkNotifyCommandEvent {
/** Stable runtime event ID generated for the emitted `spark:command` envelope. */
id: string
/** Original command event identifier inherited from the notify response flow. */
eventId: string
/** Parent `spark:notify` event ID that caused this command to be emitted. */
parentEventId: string
/** Stable per-command identifier generated for downstream orchestration. */
commandId: string
/** Interrupt mode forwarded to downstream consumers. */
interrupt: 'force' | 'soft' | false
/** Command priority used by downstream schedulers. */
priority: 'critical' | 'high' | 'normal' | 'low'
/** Intent label that describes why the downstream agent should process the command. */
intent: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
/** Optional acknowledgement text that can be surfaced by the downstream consumer. */
ack?: string
/** Optional structured guidance assembled by the notify agent for the downstream command target. */
guidance?: SparkNotifyCommandDraft['guidance']
/** Optional context patches that should accompany the emitted command. */
contexts?: SparkNotifyCommandDraft['contexts']
/** Destination agent or lane identifiers that should receive the command. */
destinations: string[]
}
/**
* Handler result after runtime command expansion finishes.
*/
export interface SparkNotifyHandleResult {
/** Expanded runtime command events ready to enqueue or emit downstream. */
commands: SparkNotifyCommandEvent[]
}
export interface SparkNotifyAgentDeps {
/**
* Snapshot of spark runtime trace artifacts for eval harnesses.
*/
export interface SparkTraceCapture {
/** Ordered trace events emitted by the runtime while handling the notify event. */
events: SparkTraceEvent[]
/** Final rendered messages passed into the model call. */
renderedMessages: Message[]
/** Tool metadata exposed to the model for the current run. */
toolExposure: Array<{
/** Provider-visible tool name. */
name: string
/** Provider-visible tool description, if supplied by the tool wrapper. */
description?: string
}>
/** Convenience list of exposed tool names extracted from `toolExposure`. */
toolNames: string[]
/** Raw model input snapshots captured before each provider call. */
modelInputs: Array<{
/** `spark:notify` event identifier associated with the model call. */
eventId: string
/** Concrete model name used for the provider request. */
model: string
/** Active provider identifier used for the model request. */
provider: string
/** Rendered chat messages sent to the provider. */
messages: Message[]
/** Provider tool selection policy, when one was enforced. */
toolChoice: ToolChoice | null
/** Whether the active provider call exposed tools at all. */
supportsTools: boolean
/** Whether the runtime waited for tool execution before finishing the call. */
waitForTools: boolean
}>
/** Raw model output events captured during streaming, including tool activity. */
modelOutputs: Array<{
/** `spark:notify` event identifier associated with the streaming output. */
eventId: string
/** Output event category emitted by the stream adapter. */
kind: 'text-delta' | 'tool-call' | 'tool-result'
/** Tool name referenced by the output event, when applicable. */
toolName?: string
/** Provider tool call identifier, when applicable. */
toolCallId?: string
/** Incremental text chunk emitted by the model. */
text?: string
/** Accumulated text at the time the output event was captured. */
accumulatedText?: string
/** Tool input payload emitted by the provider. */
input?: unknown
/** Tool execution output captured by the runtime. */
output?: unknown
/** Tool execution error captured by the runtime. */
error?: string
}>
/** Convenience view of tool-call events extracted from `modelOutputs`. */
toolCalls: Array<{
/** `spark:notify` event identifier associated with the tool call. */
eventId: string
/** Tool name referenced by the provider. */
toolName?: string
/** Provider tool call identifier. */
toolCallId?: string
/** Tool input payload captured from the provider stream. */
input?: unknown
}>
/** Convenience view of tool execution results extracted from trace events. */
toolExecutions: Array<{
/** `spark:notify` event identifier associated with the tool execution. */
eventId: string
/** Tool name executed by the runtime. */
toolName?: string
/** Provider tool call identifier. */
toolCallId?: string
/** Tool input payload passed into runtime execution. */
input?: unknown
/** Tool output payload returned by runtime execution. */
output?: unknown
/** Tool execution error, when the runtime rejected or failed the call. */
error?: string
}>
/** Final response snapshot captured after command expansion finishes. */
finalResult?: {
/** `spark:notify` event identifier associated with the final result. */
eventId: string
/** Final reaction text returned to the caller. */
reaction: string
/** Command drafts produced by the notify runtime before websocket event expansion. */
commands: SparkNotifyCommandDraft[]
/** Number of command drafts emitted for the run. */
commandCount: number
/** Whether the model selected the `builtIn_sparkNoResponse` pathway. */
noResponse: boolean
/** Whether tools were exposed to the provider for this run. */
supportsTools: boolean
}
}
/**
* Serializes one spark-notify payload into the user message content sent to the model.
*
* Use when:
* - A runtime needs the default JSON envelope for spark-notify
* - A host optionally appends one-off serialized context sections for the current run
*
* Expects:
* - `messageOverride` content to already be provider-safe text
*
* Returns:
* - A single provider-ready user message string
*/
function renderSparkNotifyUserMessage(input: {
event: WebSocketEventOf<'spark:notify'>
messageOverride?: SparkNotifyMessageOverride
}) {
if (input.messageOverride?.replaceUserMessage) {
return input.messageOverride.replaceUserMessage
}
const sections = [
JSON.stringify({
notify: input.event.data,
source: input.event.source,
}, null, 2),
...(input.messageOverride?.appendUserSections ?? []),
].filter(section => section.trim().length > 0)
return sections.join('\n\n')
}
/**
* Dependency bag required by the spark-notify runtime.
*/
export interface SparkNotifyAgentDeps extends SparkNotifyTracingHooks {
/** Streams one notify-agent model call with the provided messages and tool policy. */
stream: (
model: string,
provider: ChatProvider,
messages: Message[],
options: {
tools?: any[]
tools?: Tool[]
supportsTools?: boolean
waitForTools?: boolean
toolChoice?: ToolChoice
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
},
) => Promise<void>
/** Returns the currently selected provider name, if any. */
getActiveProvider: () => string | undefined
/** Returns the currently selected model name, if any. */
getActiveModel: () => string | undefined
/** Resolves the provider instance used for the active model call. */
getProviderInstance: <R extends
| ChatProvider
| ChatProviderWithExtraOptions
@@ -58,12 +230,19 @@ export interface SparkNotifyAgentDeps {
| TranscriptionProviderWithExtraOptions,
>(name: string,
) => Promise<R>
/** Receives incremental text deltas while the reaction is streaming. */
onReactionDelta: (eventId: string, text: string) => void
/** Receives the final reaction text after streaming completes. */
onReactionEnd: (eventId: string, text: string) => void
/** Returns the host-level system prompt prepended to notify runs. */
getSystemPrompt: () => string
/** Indicates whether the runtime is already handling another notify event. */
getProcessing: () => boolean
/** Updates the processing flag used to serialize notify handling. */
setProcessing: (next: boolean) => void
/** Returns queued `spark:notify` events that were deferred while busy. */
getPending: () => Array<WebSocketEventOf<'spark:notify'>>
/** Replaces the deferred `spark:notify` queue after enqueue/dequeue operations. */
setPending: (next: Array<WebSocketEventOf<'spark:notify'>>) => void
}
@@ -90,19 +269,73 @@ export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
].join('\n')
}
function resolveSparkNotifyRuntimePolicy(control?: SparkNotifyResponseControl): SparkNotifyRuntimePolicy {
if (control?.forceTextResponse && control?.forceSparkCommandResponse) {
console.warn('[spark:notify] forceTextResponse and forceSparkCommandResponse were both set; preferring forceTextResponse')
}
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',
},
},
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 traceSpark(deps: SparkNotifyTracingHooks, event: SparkTraceEvent) {
deps.onTrace?.(event)
}
/**
* Creates a platform-agnostic Spark Notify event handler.
*
* Use when:
* - A runtime consumes websocket `spark:notify` events
* - Reactions and command drafts should be generated by LLM with built-in tools
* - Reactions and command drafts should be generated by an LLM with built-in tools
* - You want identical behavior across stage-ui and offline eval harnesses
*
* Expects:
* - Stream/provider adapters and state accessors passed in `deps`
*
* Returns:
* - `handle(event)` function that applies queue/processing policy and returns generated commands
* - `handle(event, control)` function that applies queue/processing policy and returns generated commands
*
* Call stack:
*
@@ -113,9 +346,9 @@ export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
* -> `deps.onReactionDelta`/`deps.onReactionEnd`
*/
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
handle: (event: WebSocketEventOf<'spark:notify'>) => Promise<SparkNotifyHandleResult | undefined>
handle: (event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) => Promise<SparkNotifyHandleResult | undefined>
} {
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
const activeProvider = deps.getActiveProvider()
const activeModel = deps.getActiveModel()
if (!activeProvider || !activeModel) {
@@ -123,9 +356,9 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
return undefined
}
const runtimePolicy = resolveSparkNotifyRuntimePolicy(control)
const chatProvider = await deps.getProviderInstance<ChatProvider>(activeProvider)
const commandDrafts: SparkNotifyCommandDraft[] = []
let noResponse = false
const { tools } = await createSparkNotifyTools({
@@ -133,6 +366,9 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
noResponse = true
},
onCommands: commands => commandDrafts.push(...commands),
onTrace: deps.onTrace,
allowNoResponse: runtimePolicy.allowNoResponse,
allowSparkCommand: runtimePolicy.allowSparkCommand,
})
const systemMessage: Message = {
@@ -140,40 +376,123 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
content: [
deps.getSystemPrompt(),
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
...(control?.messageOverride?.appendSystemInstructions ?? []),
].filter(Boolean).join('\n\n'),
}
const userMessage: Message = {
role: 'user',
content: JSON.stringify({
notify: event.data,
source: event.source,
}, null, 2),
content: renderSparkNotifyUserMessage({
event,
messageOverride: control?.messageOverride,
}),
}
const messages: Message[] = [systemMessage, userMessage]
traceSpark(deps, {
type: 'messages-rendered',
payload: {
eventId: event.data.eventId,
source: event.source,
messageCount: messages.length,
toolCount: tools.length,
renderedMessages: messages,
},
})
traceSpark(deps, {
type: 'tools-prepared',
payload: {
eventId: event.data.eventId,
toolNames: tools.flatMap((tool) => {
const name = tool.function?.name
return name ? [name] : []
}),
toolExposure: tools.flatMap((tool) => {
const name = tool.function?.name
if (!name)
return []
return [{
name,
description: tool.function?.description,
}]
}),
allowNoResponse: runtimePolicy.allowNoResponse,
allowSparkCommand: runtimePolicy.allowSparkCommand,
supportsTools: runtimePolicy.supportsTools,
waitForTools: runtimePolicy.waitForTools,
},
})
traceSpark(deps, {
type: 'model-input',
payload: {
eventId: event.data.eventId,
model: activeModel,
provider: activeProvider,
messages,
toolChoice: runtimePolicy.toolChoice ?? null,
supportsTools: runtimePolicy.supportsTools,
waitForTools: runtimePolicy.waitForTools,
},
})
let fullText = ''
await deps.stream(activeModel, chatProvider, [systemMessage, userMessage], {
await deps.stream(activeModel, chatProvider, messages, {
tools,
supportsTools: true,
waitForTools: true,
supportsTools: runtimePolicy.supportsTools,
waitForTools: runtimePolicy.waitForTools,
toolChoice: runtimePolicy.toolChoice,
onStreamEvent: async (streamEvent: StreamEvent) => {
if (streamEvent.type === 'text-delta') {
if (noResponse)
if (runtimePolicy.ignoreTextOutput || noResponse)
return
const nextText = `${fullText}${streamEvent.text}`
traceSpark(deps, {
type: 'model-output-text',
payload: {
eventId: event.data.id,
text: streamEvent.text,
accumulatedText: nextText,
},
})
deps.onReactionDelta(event.data.id, streamEvent.text)
fullText += streamEvent.text
fullText = nextText
}
if (streamEvent.type === 'tool-call') {
traceSpark(deps, {
type: 'model-output-tool-call',
payload: {
eventId: event.data.eventId,
kind: 'tool-call',
...streamEvent,
},
})
}
if (streamEvent.type === 'tool-result') {
traceSpark(deps, {
type: 'tool-execution',
payload: {
eventId: event.data.eventId,
kind: 'tool-result',
...streamEvent,
},
})
}
if (streamEvent.type === 'finish') {
if (noResponse) {
deps.onReactionEnd(event.data.id, '')
return
}
deps.onReactionEnd(event.data.id, fullText)
else {
deps.onReactionEnd(event.data.id, fullText)
}
}
if (streamEvent.type === 'error') {
deps.onReactionEnd(event.data.id, fullText)
throw streamEvent.error ?? new Error('Spark notify stream error')
@@ -181,13 +500,28 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
},
})
const reaction = fullText.trim()
traceSpark(deps, {
type: 'result',
payload: {
eventId: event.data.eventId,
reaction,
commandCount: commandDrafts.length,
noResponse,
supportsTools: runtimePolicy.supportsTools,
commands: commandDrafts,
normalizedReaction: reaction,
normalizedCommands: commandDrafts,
},
})
return {
reaction: fullText.trim(),
reaction,
commands: commandDrafts,
} satisfies SparkNotifyResponse
}
async function handle(event: WebSocketEventOf<'spark:notify'>): Promise<SparkNotifyHandleResult | undefined> {
async function handle(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl): Promise<SparkNotifyHandleResult | undefined> {
if (event.data.urgency !== 'immediate' && deps.getPending().length > 0) {
deps.setPending([...deps.getPending(), event])
return undefined
@@ -200,7 +534,7 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
deps.setProcessing(true)
try {
const response = await runNotifyAgent(event)
const response = await runNotifyAgent(event, control)
if (!response)
return undefined
@@ -3,6 +3,7 @@ export type {
SparkNotifyCommandEvent,
SparkNotifyHandleResult,
SparkNotifyResponse,
SparkTraceCapture,
} from './handler'
export {
getSparkNotifyHandlingAgentInstruction,
@@ -18,3 +19,10 @@ export type {
SparkNotifyCommandDraft,
} from './tools'
export { createSparkNotifyTools } from './tools'
export type {
SparkNotifyMessageOverride,
SparkNotifyResponseControl,
SparkNotifyRuntimePolicy,
SparkNotifyTracingHooks,
SparkTraceEvent,
} from './types'
@@ -1,4 +1,10 @@
import type { ContextUpdate } from '@proj-airi/server-sdk'
import type { Tool } from '@xsai/shared-chat'
import type {
SparkNotifyTracingHooks,
SparkTraceEvent,
} from './types'
import { errorMessageFrom } from '@moeru/std'
import { rawTool } from '@xsai/tool'
@@ -10,42 +16,61 @@ import {
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
/**
* Whether to expose the `builtIn_sparkNoResponse` tool.
*
* @default true
*/
/** Receives trace events describing tool exposure and execution. */
onTrace?: SparkNotifyTracingHooks['onTrace']
/** Enables or disables registration of the `builtIn_sparkNoResponse` tool. */
allowNoResponse?: boolean
/**
* Whether to expose the `builtIn_sparkCommand` tool.
*
* @default true
*/
/** Enables or disables registration of the `builtIn_sparkCommand` tool. */
allowSparkCommand?: boolean
}
@@ -107,20 +132,86 @@ function normalizeSparkNotifyCommand(
* - Tool array consumable by `@xsai/stream-text`
*/
export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOptions) {
const { allowNoResponse = true, allowSparkCommand = true } = options
const allowNoResponse = options.allowNoResponse !== false
const allowSparkCommand = options.allowSparkCommand !== false
const tools: Array<ReturnType<typeof rawTool>> = []
const tools: Tool[] = []
if (allowNoResponse) {
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 () => {
options.onNoResponse()
return 'AIRI System: Acknowledged, no response or action will be processed.'
},
}))
const sparkNoResponseTool = 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.onTrace?.({
type: 'model-output-tool-call',
payload: {
toolName: 'builtIn_sparkNoResponse',
toolCallId: context?.toolCallId,
},
} satisfies SparkTraceEvent)
options.onNoResponse()
options.onTrace?.({
type: 'tool-execution',
payload: {
toolName: 'builtIn_sparkNoResponse',
toolCallId: context?.toolCallId,
responseMode: 'no-response',
},
} satisfies SparkTraceEvent)
return 'AIRI System: Acknowledged, no response or action will be processed.'
},
})
if (allowNoResponse)
tools.push(sparkNoResponseTool)
const sparkCommandTool = rawTool({
name: 'builtIn_sparkCommand',
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.onTrace?.({
type: 'model-output-tool-call',
payload: {
toolName: 'builtIn_sparkCommand',
toolCallId: context?.toolCallId,
rawPayload,
},
} satisfies SparkTraceEvent)
try {
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
const validated = await validate(sparkNotifyCommandSchema, payload)
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
options.onTrace?.({
type: 'tool-execution',
payload: {
toolName: 'builtIn_sparkCommand',
toolCallId: context?.toolCallId,
commandCount: validated.commands.length,
},
} satisfies SparkTraceEvent)
}
catch (error) {
options.onTrace?.({
type: 'tool-execution',
payload: {
toolName: 'builtIn_sparkCommand',
toolCallId: context?.toolCallId,
ok: false,
error: errorMessageFrom(error),
},
} satisfies SparkTraceEvent)
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
}
return 'AIRI System: Acknowledged, command fired.'
},
})
if (allowSparkCommand)
tools.push(sparkCommandTool)
return {
tools,
}
if (allowSparkCommand) {
@@ -0,0 +1,128 @@
import type { ToolChoice } from '@xsai/shared-chat'
/**
* Runtime-only prompt hints used to reshape how one spark event is serialized for the model.
*
* These overrides are intentionally kept out of transport protocol types.
* They are host-local rendering hints, not part of the canonical spark event payload.
*/
export interface SparkNotifyMessageOverride {
/**
* Additional system instructions appended after the base spark-notify instruction block.
*
* @default []
*/
appendSystemInstructions?: string[]
/**
* Additional serialized sections appended after the default user payload serialization.
*
* Use when:
* - A host wants to inject a pre-rendered message fragment for one run
* - A plugin temporarily needs extra readable context without changing the protocol schema
*
* Expects:
* - Entries already serialized into provider-safe text
*
* @default []
*/
appendUserSections?: string[]
/**
* Replaces the default JSON user payload serialization entirely for one run.
*
* @default undefined
*/
replaceUserMessage?: string
}
/**
* Caller-provided overrides that shape how the `spark:notify` runtime must respond.
*/
export interface SparkNotifyResponseControl {
/**
* Forces the runtime to produce some output instead of choosing the no-response tool.
*
* Use when:
* - The host requires a visible or actionable outcome for the notify event
*
* Expects:
* - Text output and spark-command tool calls are both still allowed
*
* @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.
*
* Use when:
* - The host needs the notify run to emit downstream commands only
* - Reaction text should not leak into the user-visible channel
*
* Expects:
* - The runtime exposes tools and waits for tool execution before completing
*
* @default false
*/
forceSparkCommandResponse?: boolean
/**
* Host-local message serialization override applied only while rendering the current notify turn.
*
* @default undefined
*/
messageOverride?: SparkNotifyMessageOverride
}
/**
* Trace event emitted by the spark-notify runtime.
*/
export interface SparkTraceEvent {
/** 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 SparkNotifyTracingHooks {
/** Optional sink that receives ordered trace events from the notify runtime. */
onTrace?: (event: SparkTraceEvent) => void
}
/**
* Resolved runtime response policy derived from `SparkNotifyResponseControl`.
*/
export interface SparkNotifyRuntimePolicy {
/** Whether the `builtIn_sparkNoResponse` tool is exposed for the current run. */
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
}
@@ -0,0 +1,73 @@
import type { Message, RawMessage } from './types'
import { describe, expect, it } from 'vitest'
import { compactConversationEntries } from './compaction'
describe('compactConversationEntries', () => {
it('compacts older chess turns while preserving recent move-reaction pairs', () => {
const result = compactConversationEntries({
entries: [
{
role: 'user',
content: 'weather?',
} 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',
},
},
{
type: 'reaction',
reactionType: 'spark-command',
text: 'Hmm.',
},
{
type: 'turn',
turnType: 'chess',
turnIndex: 2,
actor: 'assistant',
action: {
kind: 'move-executed',
san: 'e5',
},
},
{
type: 'reaction',
reactionType: 'spark-command',
text: 'Let us answer.',
},
{
type: 'domain-event',
eventType: 'board-updated',
payload: {
fen: 'startpos',
},
},
],
},
],
} satisfies Message,
],
recentTurnLimit: 1,
})
expect(result).toHaveLength(2)
expect(JSON.stringify(result)).toContain('Let us answer.')
expect(JSON.stringify(result)).toContain('compacted')
expect(JSON.stringify(result)).toContain('board-updated')
})
})
@@ -0,0 +1,118 @@
import type { HistoryItem, Message, MessageHistoryBlockSegment, RawMessage } from './types'
/**
* Options for compacting projected conversation history.
*/
export interface CompactConversationEntriesOptions {
/** Ordered conversation entries to compact in place. */
entries: Array<Message | RawMessage>
/** Maximum number of explicit `turn` items to preserve inside each history block. */
recentTurnLimit: number
/** Optional domain-aware summary formatter used for removed history windows. */
summarizeCompactedHistory?: (input: {
removedTurnCount: number
originalItems: HistoryItem[]
keptItems: HistoryItem[]
}) => 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.
*
* Use when:
* - Long-running conversations need a smaller prompt footprint
* - Domain history must keep recent turn/reaction pairs intact without hardcoding one plugin format
*
* Expects:
* - History blocks to be ordered chronologically
* - `recentTurnLimit` to be a positive integer
* - `summarizeCompactedHistory`, when provided, returns domain-specific summary text for removed history
*
* Returns:
* - A new entry array with eligible history blocks compacted in place
*/
export function compactConversationEntries(input: CompactConversationEntriesOptions): Array<Message | RawMessage> {
if (input.recentTurnLimit <= 0)
return input.entries
return input.entries.map((entry) => {
if (!isStructuredMessage(entry))
return entry
return {
...entry,
segments: entry.segments.map((segment) => {
if (segment.type !== 'history-block')
return segment
return compactHistoryBlock(segment, input.recentTurnLimit, input.summarizeCompactedHistory)
}),
}
})
}
+42
View File
@@ -0,0 +1,42 @@
export { compactConversationEntries } from './compaction'
export type {
Projection,
ProjectionCompactedHistory,
ProjectionDomainEvent,
ProjectionSessionUserTurn,
ProjectionSparkCommand,
ProjectionSparkNotify,
} from './projection'
export { projectConversationEntries, projectProjection } from './projection'
export { renderProviderChatMessages } from './render-provider-chat'
export type {
HistoryItem,
HistoryItemDomainEvent,
HistoryReaction,
HistorySummary,
HistoryTurn,
HistoryTurnAction,
HistoryTurnEventAction,
HistoryTurnGenericAction,
HistoryTurnMoveAction,
HistoryTurnTextAction,
Message,
MessageDomainEventSegment,
MessageHistoryBlockSegment,
MessageInstructionSegment,
MessageReferenceSegment,
MessageSegment,
MessageStateSnapshotSegment,
MessageSummarySegment,
MessageTaggedTextSegment,
MessageTextSegment,
RawMessage,
SegmentDomainEvent,
SegmentHistoryBlock,
SegmentInstruction,
SegmentReference,
SegmentStateSnapshot,
SegmentSummary,
SegmentTaggedText,
SegmentText,
} from './types'
@@ -0,0 +1,90 @@
import type { Message, RawMessage } from './types'
import { describe, expect, it } from 'vitest'
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',
name: 'move-resolved',
payload: {
moveSan: 'e4',
},
})
expect(result).toHaveLength(1)
const projected = result[0] as Message
expect(projected.role).toBe('event')
expect(projected.segments[0].type).toBe('domain-event')
expect(projected.segments[1].type).toBe('reference')
})
})
describe('projectConversationEntries', () => {
it('keeps existing entries before projected entries', () => {
const entries: Array<Message | RawMessage> = [
{
role: 'system',
content: 'system',
},
]
const result = projectConversationEntries({
entries,
projections: [
{
type: 'session-user-turn',
id: 'turn-1',
content: 'hello',
},
],
})
expect(result).toHaveLength(2)
expect(result[0]).toBe(entries[0])
expect(result[1].role).toBe('user')
})
it('projects spark notify and command payloads into structured segments', () => {
const result = projectConversationEntries({
entries: [],
projections: [
{
type: 'spark-notify',
id: 'notify-1',
source: 'plugin:airi-plugin-game-chess',
headline: 'chess update',
note: 'Project a board update',
payload: {
fen: 'startpos',
},
destinations: ['character'],
},
{
type: 'spark-command',
id: 'command-1',
source: 'plugin:airi-plugin-game-chess',
commandId: 'command-1',
parentEventId: 'notify-1',
intent: 'action',
ack: 'play e5',
destinations: ['character'],
},
],
})
expect(result).toHaveLength(2)
const notify = result[0] as Message
const command = result[1] as Message
expect(notify.segments[0].type).toBe('instruction')
expect(notify.segments[1].type).toBe('tagged-text')
expect(notify.segments[2].type).toBe('reference')
expect(command.segments[0].type).toBe('instruction')
expect(command.segments[1].type).toBe('tagged-text')
expect(command.segments[2].type).toBe('state-snapshot')
})
})
@@ -0,0 +1,248 @@
import type { HistoryItem, Message, RawMessage } from './types'
/**
* Projection payload for one user-authored session turn.
*/
export interface ProjectionSessionUserTurn {
type: 'session-user-turn'
id: string
content: string
metadata?: Record<string, unknown>
}
/**
* Projection payload for one `spark:notify` event.
*/
export interface ProjectionSparkNotify {
type: 'spark-notify'
id: string
source: string
headline: string
note?: string
payload?: Record<string, unknown>
destinations: string[]
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>
}
/**
* Projection payload for one structured domain event.
*/
export interface ProjectionDomainEvent {
type: 'domain-event'
id: string
domain: string
name?: string
payload: Record<string, unknown>
metadata?: Record<string, unknown>
}
/**
* Projection payload for one already-compacted history block.
*/
export interface ProjectionCompactedHistory {
type: 'compacted-history'
id: string
source?: string
summary?: string
items: HistoryItem[]
metadata?: Record<string, unknown>
}
/**
* Union of projection payloads accepted by the generic message projection pipeline.
*/
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
}
/**
* 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 [{
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,
}]
}
/**
* Projects raw or structured entries into a single ordered conversation list.
*
* Use when:
* - Building a full provider prompt from session messages and structured projections
* - Appending projected session, spark, or domain events to history
*
* Expects:
* - Inputs already ordered by the caller
*
* Returns:
* - The original entries followed by projection-derived entries
*/
export function projectConversationEntries(input: {
entries: Array<Message | RawMessage>
projections: Projection[]
}): Array<Message | RawMessage> {
return [
...input.entries,
...input.projections.flatMap(projectProjection),
]
}
@@ -0,0 +1,108 @@
import type { Message, RawMessage } from './types'
import { describe, expect, it } from 'vitest'
import { renderProviderChatMessages } from './render-provider-chat'
describe('renderProviderChatMessages', () => {
it('renders structured event messages into raw provider chat messages', () => {
const entries: Array<Message | RawMessage> = [
{
role: 'system',
content: 'system prompt',
},
{
id: 'event-1',
role: 'event',
source: 'plugin:airi-plugin-game-chess',
segments: [
{
type: 'instruction',
text: 'Keep the reply short.',
priority: 'critical',
},
{
type: 'text',
text: 'Chess update',
},
{
type: 'tagged-text',
tag: 'agent_spark_command_reaction',
text: 'Move accepted.',
},
{
type: 'domain-event',
eventType: 'board-updated',
payload: {
fen: 'startpos',
},
},
{
type: 'state-snapshot',
stateType: 'board',
payload: {
fen: 'startpos',
},
},
{
type: 'history-block',
compacted: true,
items: [
{
type: 'summary',
text: 'Compacted history.',
fromTurnIndex: 1,
toTurnIndex: 3,
},
{
type: 'turn',
turnType: 'chess',
turnIndex: 3,
actor: 'assistant',
action: {
kind: 'move-executed',
san: 'e5',
},
},
{
type: 'domain-event',
eventType: 'board-updated',
payload: {
fen: 'startpos',
},
},
],
},
{
type: 'summary',
text: 'Earlier turns compacted.',
metadata: {
span: 2,
},
},
{
type: 'reference',
refType: 'turn',
targetId: 'turn-2',
note: 'Recent move',
},
],
},
]
const rendered = renderProviderChatMessages({
entries,
mode: 'session-spark-notify',
})
expect(rendered).toHaveLength(2)
expect(rendered[0].content).toBe('system prompt')
expect(rendered[1].role).toBe('system')
expect(rendered[1].content).toContain('Instruction [critical]:')
expect(rendered[1].content).toContain('<agent_spark_command_reaction>Move accepted.</agent_spark_command_reaction>')
expect(rendered[1].content).toContain('Domain event: board-updated')
expect(rendered[1].content).toContain('State snapshot: board')
expect(rendered[1].content).toContain('Summary:')
expect(rendered[1].content).toContain('Reference: turn -> turn-2')
})
})
@@ -0,0 +1,120 @@
import type { HistoryItem, Message, MessageSegment, RawMessage } from './types'
function renderHistoryAction(item: HistoryItem) {
if (item.type === 'summary') {
return [
'Summary:',
item.text,
item.fromTurnIndex != null || item.toTurnIndex != null
? `Window: ${item.fromTurnIndex ?? '?'} -> ${item.toTurnIndex ?? '?'}.`
: undefined,
].filter(Boolean).join('\n')
}
if (item.type === 'reaction')
return `${item.reactionType}: ${item.text}`
if (item.type === 'domain-event') {
return [
`Domain event: ${item.eventType}`,
JSON.stringify(item.payload, null, 2),
].join('\n')
}
if (item.action.kind === 'text')
return item.action.text
if (item.action.kind === 'event')
return `${item.action.name}${item.action.payload ? ` ${JSON.stringify(item.action.payload)}` : ''}`
if (item.action.kind === 'move-played' || item.action.kind === 'move-executed')
return `${item.action.kind} ${item.action.san}`
return JSON.stringify(item.action)
}
function renderSegmentText(segment: MessageSegment): string {
if (segment.type === 'text')
return segment.text
if (segment.type === 'instruction') {
return [
segment.priority ? `Instruction [${segment.priority}]:` : 'Instruction:',
segment.text,
].join('\n')
}
if (segment.type === 'tagged-text')
return `<${segment.tag}>${segment.text}</${segment.tag}>`
if (segment.type === 'domain-event') {
return [
`Domain event: ${segment.eventType}`,
JSON.stringify(segment.payload, null, 2),
].join('\n')
}
if (segment.type === 'state-snapshot') {
return [
`State snapshot: ${segment.stateType}`,
JSON.stringify(segment.payload, null, 2),
].join('\n')
}
if (segment.type === 'summary') {
return [
'Summary:',
segment.text,
segment.metadata ? JSON.stringify(segment.metadata, null, 2) : undefined,
].filter(Boolean).join('\n')
}
if (segment.type === 'reference') {
return [
`Reference: ${segment.refType} -> ${segment.targetId}`,
segment.note,
].filter(Boolean).join('\n')
}
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,
}
})
}
@@ -0,0 +1,126 @@
import type {
HistoryItem,
Message,
RawMessage,
SegmentDomainEvent,
SegmentHistoryBlock,
SegmentInstruction,
SegmentReference,
SegmentStateSnapshot,
SegmentSummary,
SegmentTaggedText,
SegmentText,
} from './types'
import { describe, expect, it } from 'vitest'
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',
},
},
{
type: 'reaction',
reactionType: 'spark-command',
text: 'Good move.',
},
{
type: 'domain-event',
eventType: 'board-updated',
payload: {
fen: 'startpos',
},
},
]
const segments: Array<
SegmentText
| SegmentInstruction
| SegmentTaggedText
| SegmentDomainEvent
| SegmentStateSnapshot
| SegmentHistoryBlock
| SegmentSummary
| SegmentReference
> = [
{
type: 'instruction',
text: 'Explain the current board state.',
priority: 'high',
},
{
type: 'tagged-text',
tag: 'agent_spark_command_reaction',
text: 'Good move.',
},
{
type: 'domain-event',
eventType: 'board-updated',
payload: {
fen: 'startpos',
},
},
{
type: 'state-snapshot',
stateType: 'board',
payload: {
fen: 'startpos',
},
},
{
type: 'summary',
text: 'Older chess turns compacted.',
},
{
type: 'reference',
refType: 'turn',
targetId: 'turn-1',
note: 'Latest paired move',
},
{
type: 'history-block',
compacted: false,
items: history,
},
]
const structuredMessage: Message = {
id: 'msg-1',
role: 'event',
source: 'plugin:airi-plugin-game-chess',
segments,
metadata: {
domain: 'chess',
},
}
const rawMessage: RawMessage = {
role: 'user',
content: 'continue',
metadata: {
source: 'session',
},
}
const historyBlock = structuredMessage.segments[6] as SegmentHistoryBlock
expect(structuredMessage.segments).toHaveLength(7)
expect(structuredMessage.segments[0].type).toBe('instruction')
expect(structuredMessage.segments[1].type).toBe('tagged-text')
expect(structuredMessage.segments[2].type).toBe('domain-event')
expect(structuredMessage.segments[3].type).toBe('state-snapshot')
expect(structuredMessage.segments[4].type).toBe('summary')
expect(structuredMessage.segments[5].type).toBe('reference')
expect(structuredMessage.segments[6].type).toBe('history-block')
expect(historyBlock.items).toHaveLength(3)
expect(rawMessage.role).toBe('user')
expect(rawMessage.content).toBe('continue')
})
})
+266
View File
@@ -0,0 +1,266 @@
/**
* 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 {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
name?: string
metadata?: Record<string, unknown>
}
/**
* Rich message projected from session, spark, or domain data.
*
* Use when:
* - You need structured message segments
* - You want to preserve history blocks, summaries, or other contextual payloads
*
* Expects:
* - `segments` to describe the full rendered message content
*
* Returns:
* - A structured message that can be compacted or rendered later
*/
export interface Message {
id: string
role: 'system' | 'user' | 'assistant' | 'context' | 'event' | 'summary'
source?: string
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.
*/
export type MessageSummarySegment = SegmentSummary
/**
* Alias for the reference segment shape used by the approved spec.
*/
export type MessageReferenceSegment = SegmentReference
/**
* Summary segment for historical or narrative windows.
*/
export interface SegmentSummary {
type: 'summary'
text: string
metadata?: Record<string, unknown>
}
/**
* Reference segment for stable pointers to prior messages or resources.
*/
export interface SegmentReference {
type: 'reference'
refType: string
targetId: string
note?: string
}
+13 -1
View File
@@ -113,7 +113,19 @@ export async function streamFrom({
// NOTICE: Consume underlying promises to prevent unhandled rejections from
// @xsai/stream-text's SSE parser surfacing as faulted app state.
void streamResult.steps.catch((error) => {
// NOTICE:
// `streamText(...).steps` is the authoritative completion signal for the
// full streamed interaction, including tool-call rounds.
// Resolving only from `onEvent({ type: 'finish' })` is incorrect when
// `options?.waitForTools === true`, because providers can emit
// `finishReason: 'tool_calls'` or `finishReason: 'tool-calls'` before the
// tool round has fully settled.
// That misuse leaves the outer promise pending, which makes provider-backed
// eval tasks look like they stop mid-run and prevents later scheduled evals
// from starting.
// Keep `steps.then(resolveOnce)` so evaluation runners observe the real end
// of the stream lifecycle instead of an intermediate tool boundary.
void streamResult.steps.then(resolveOnce).catch((error) => {
rejectOnce(error)
console.error('Stream steps error:', error)
})
+78
View File
@@ -0,0 +1,78 @@
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vieval'
import { chatModelFrom, ChatModels, chatProviderFrom, ChatProviders } from 'vieval/plugins/chat-models'
const pluginRootDirectory = dirname(fileURLToPath(import.meta.url))
const loadedEnv = loadEnv('test', pluginRootDirectory, '')
const defaultModel = loadedEnv.OPENAI_MODEL ?? loadedEnv.OPENAI_CHAT_MODEL ?? 'openai/gpt-5.4-mini'
/**
* Vieval config for the core-agent runtime competition.
*/
const coreAgentVievalConfig = defineConfig({
plugins: [
ChatProviders({
providers: [
chatProviderFrom({
id: 'openrouter-provider',
inferenceExecutor: 'openrouter',
optionalEnv: {
baseURL: 'OPENROUTER_BASE_URL',
},
requiredEnv: {
apiKey: 'OPENROUTER_API_KEY',
},
}),
],
}),
ChatModels({
models: [
chatModelFrom({
aliases: ['default', 'competition'],
provider: 'openrouter-provider',
model: defaultModel,
}),
],
}),
],
env: loadedEnv,
projects: [
{
name: 'round-3-primary-control',
root: '.',
include: ['evals/round-3-primary-control/**/*.eval.ts'],
exclude: ['dist/**', 'node_modules/**'],
runMatrix: {
override: {
model: [defaultModel],
},
},
},
{
name: 'round-3-takeover-control',
root: '.',
include: ['evals/round-3-takeover-control/**/*.eval.ts'],
exclude: ['dist/**', 'node_modules/**'],
runMatrix: {
override: {
model: [defaultModel],
},
},
},
{
name: 'round-3-sidecar-control',
root: '.',
include: ['evals/round-3-sidecar-control/**/*.eval.ts'],
exclude: ['dist/**', 'node_modules/**'],
runMatrix: {
override: {
model: [defaultModel],
},
},
},
],
})
export default coreAgentVievalConfig
@@ -196,4 +196,133 @@ describe('store character-orchestrator', () => {
expect(mockOnSparkNotifyReactionStreamEvent).toBeCalledWith(event.data.id, 'Ahhh, got hit by zombie!')
expect(mockOnSparkNotifyReactionStreamEnd).toBeCalledTimes(1)
})
it('supports forcing text-only spark:notify responses', async () => {
const mockStream = vi.fn()
mockedStore(useLLM).stream = mockStream
mockedStore(useLLM).stream.mockImplementation(async (_model: string, _provider: unknown, _messages: unknown, options: any) => {
await options?.onStreamEvent?.({ type: 'text-delta', text: 'I choose d5 to pressure the center.' } satisfies StreamEvent)
await options?.onStreamEvent?.({ type: 'finish' } satisfies StreamEvent)
})
const onDelta = vi.fn()
const onEnd = vi.fn()
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEvent = onDelta
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEnd = onEnd
const store = useCharacterOrchestratorStore()
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'plugin:airi-plugin-game-chess',
data: {
id: nanoid(),
eventId: nanoid(),
kind: 'ping',
urgency: 'immediate',
headline: 'AIRI played d5',
destinations: ['character'],
},
}
await store.handleSparkNotifyWithReaction(event, {
forceTextResponse: true,
})
const streamOptions = mockStream.mock.calls[0][3]
expect(streamOptions.supportsTools).toBe(false)
expect(streamOptions.waitForTools).toBe(false)
expect(streamOptions.tools).toEqual([])
expect(streamOptions.toolChoice).toBeUndefined()
expect(onDelta).toBeCalled()
expect(onEnd).toBeCalled()
})
it('supports forcing spark-command responses', async () => {
const mockStream = vi.fn()
mockedStore(useLLM).stream = mockStream
mockedStore(useLLM).stream.mockImplementation(async (_model: string, _provider: unknown, _messages: unknown, options: any) => {
const sparkCommandTool = options?.tools?.find((tool: any) => tool.function?.name === 'builtIn_sparkCommand')
await sparkCommandTool.execute({
commands: [{
destinations: ['minecraft'],
intent: 'action',
priority: 'high',
interrupt: 'false',
ack: 'go',
guidance: null,
}],
} satisfies z.infer<typeof sparkNotifyCommandSchema>)
await options?.onStreamEvent?.({ type: 'text-delta', text: 'This should be ignored.' } satisfies StreamEvent)
await options?.onStreamEvent?.({ type: 'finish' } satisfies StreamEvent)
})
const onDelta = vi.fn()
const onEnd = vi.fn()
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEvent = onDelta
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEnd = onEnd
const store = useCharacterOrchestratorStore()
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'minecraft',
data: {
id: nanoid(),
eventId: nanoid(),
kind: 'alarm',
urgency: 'immediate',
headline: 'Take cover',
destinations: ['character'],
},
}
const result = await store.handleSparkNotify(event, {
forceSparkCommandResponse: true,
})
const streamOptions = mockStream.mock.calls[0][3]
expect(streamOptions.supportsTools).toBe(true)
expect(streamOptions.waitForTools).toBe(true)
expect(streamOptions.toolChoice).toEqual({
type: 'function',
function: { name: 'builtIn_sparkCommand' },
})
expect(result?.commands?.length).toBe(1)
expect(onDelta).not.toBeCalled()
expect(onEnd).toBeCalledWith(event.data.id, '')
})
it('forwards runtime-only message overrides into the rendered spark prompt', async () => {
const mockStream = vi.fn()
mockedStore(useLLM).stream = mockStream
mockedStore(useLLM).stream.mockImplementation(async (_model: string, _provider: unknown, _messages: unknown, options: any) => {
await options?.onStreamEvent?.({ type: 'text-delta', text: 'legacy-safe text' } satisfies StreamEvent)
await options?.onStreamEvent?.({ type: 'finish' } satisfies StreamEvent)
})
const store = useCharacterOrchestratorStore()
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'plugin:airi-plugin-game-chess',
data: {
id: nanoid(),
eventId: nanoid(),
kind: 'ping',
urgency: 'immediate',
headline: 'Legacy rendering',
destinations: ['character'],
},
}
await store.handleSparkNotify(event, {
forceTextResponse: true,
messageOverride: {
appendSystemInstructions: ['Plugin-specific hint'],
appendUserSections: ['Rendered board snapshot'],
},
})
const renderedMessages = mockStream.mock.calls[0]?.[2] as Array<{ role: string, content: string }> | undefined
expect(String(renderedMessages?.[0]?.content)).toContain('Plugin-specific hint')
expect(String(renderedMessages?.[1]?.content)).toContain('Rendered board snapshot')
})
})
@@ -1,10 +1,19 @@
export type {
SparkNotifyAgentDeps,
SparkNotifyCommandDraft,
SparkNotifyCommandEvent,
SparkNotifyCommandSchema,
SparkNotifyHandleResult,
SparkNotifyMessageOverride,
SparkNotifyResponse,
SparkNotifyResponseControl,
SparkNotifyRuntimePolicy,
SparkNotifyTracingHooks,
SparkTraceCapture,
SparkTraceEvent,
} from '@proj-airi/core-agent/agents/spark-notify'
export {
getSparkNotifyHandlingAgentInstruction,
setupAgentSparkNotifyHandler,
sparkNotifyCommandSchema,
} from '@proj-airi/core-agent/agents/spark-notify'
@@ -1,5 +1,7 @@
import type { SparkNotifyResponseControl } from '@proj-airi/core-agent/agents/spark-notify'
import type { WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
import { setupAgentSparkNotifyHandler } from '@proj-airi/core-agent/agents/spark-notify'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
@@ -8,9 +10,8 @@ import { useLLM } from '../../llm'
import { useModsServerChannelStore } from '../../mods/api/channel-server'
import { useConsciousnessStore } from '../../modules/consciousness'
import { useProvidersStore } from '../../providers'
import { setupAgentSparkNotifyHandler } from './agents/event-handler-spark-notify'
export { sparkNotifyCommandSchema } from './agents/event-handler-spark-notify'
export { sparkNotifyCommandSchema } from '@proj-airi/core-agent/agents/spark-notify'
export const useCharacterOrchestratorStore = defineStore('character-orchestrator', () => {
const { stream } = useLLM()
@@ -25,6 +26,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
const pendingNotifies = ref<Array<WebSocketEventOf<'spark:notify'>>>([])
const scheduledNotifies = ref<Array<{
event: WebSocketEventOf<'spark:notify'>
control?: SparkNotifyResponseControl
enqueuedAt: number
nextRunAt: number
attempts: number
@@ -76,13 +78,22 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
pendingNotifies.value = pendingNotifies.value.filter(item => item.data.id !== eventId)
}
function enqueueSparkNotify(event: WebSocketEventOf<'spark:notify'>, options?: { reason?: string, nextRunAt?: number, maxAttempts?: number }) {
function enqueueSparkNotify(
event: WebSocketEventOf<'spark:notify'>,
options?: {
reason?: string
nextRunAt?: number
maxAttempts?: number
control?: SparkNotifyResponseControl
},
) {
if (!pendingNotifies.value.some(item => item.data.id === event.data.id)) {
pendingNotifies.value.push(event)
}
scheduledNotifies.value.push({
event,
control: options?.control,
enqueuedAt: Date.now(),
nextRunAt: options?.nextRunAt ?? computeNextRunAt(event, 0),
attempts: 0,
@@ -91,8 +102,8 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
})
}
async function processSparkNotify(event: WebSocketEventOf<'spark:notify'>) {
const result = await sparkNotifyAgent.handle(event)
async function processSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
const result = await sparkNotifyAgent.handle(event, control)
if (!result?.commands?.length)
return result
@@ -106,15 +117,30 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
return result
}
async function handleIncomingSparkNotify(event: WebSocketEventOf<'spark:notify'>) {
async function handleIncomingSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
if (event.data.urgency === 'immediate' && !processing.value) {
return await processSparkNotify(event)
return await processSparkNotify(event, control)
}
enqueueSparkNotify(event, { reason: 'spark:notify' })
enqueueSparkNotify(event, { reason: 'spark:notify', control })
return undefined
}
async function handleSparkNotifyWithReaction(
event: WebSocketEventOf<'spark:notify'>,
options?: SparkNotifyResponseControl & { fallbackText?: string },
) {
await handleIncomingSparkNotify(event, options)
const reaction = [...characterStore.reactions]
.reverse()
.find(item => item.sourceEventId === event.data.id)
?.message
?.trim()
return reaction || options?.fallbackText || ''
}
function enqueueDueTasks(now: number) {
const dueTasks = notebookStore.getDueTasks(now, attentionConfig.value.taskNotifyWindowMs)
if (!dueTasks.length)
@@ -160,7 +186,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
removePending(next.event.data.id)
try {
await processSparkNotify(next.event)
await processSparkNotify(next.event, next.control)
}
catch (error) {
if (next.attempts + 1 < next.maxAttempts) {
@@ -253,6 +279,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
dispose,
handleSparkNotify: handleIncomingSparkNotify,
handleSparkNotifyWithReaction,
handleSparkEmit,
}
})
@@ -1,8 +1,6 @@
import type { ChatHistoryItem } from '../../types/chat'
import assert from 'node:assert/strict'
import { describe, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { mergeLoadedSessionMessages } from './session-message-merge'
@@ -16,7 +14,7 @@ describe('mergeLoadedSessionMessages', () => {
{ role: 'system', content: 'system', createdAt: 3, id: 'system-current' },
]
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toBe(storedMessages)
})
it('appends in-flight messages when IndexedDB finishes loading after a new send starts', () => {
@@ -29,7 +27,7 @@ describe('mergeLoadedSessionMessages', () => {
{ role: 'user', content: 'latest prompt', createdAt: 4, id: 'user-2' },
]
assert.deepEqual(mergeLoadedSessionMessages(storedMessages, currentMessages), [
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toEqual([
...storedMessages,
currentMessages[1],
])
@@ -45,7 +43,7 @@ describe('mergeLoadedSessionMessages', () => {
{ role: 'user', content: 'latest prompt', createdAt: 4 },
]
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toBe(storedMessages)
})
it('keeps a system message when storage is empty and current has in-flight user messages', () => {
@@ -55,7 +53,7 @@ describe('mergeLoadedSessionMessages', () => {
{ role: 'user', content: 'in-flight prompt', createdAt: 2, id: 'user-1' },
]
assert.deepEqual(mergeLoadedSessionMessages(storedMessages, currentMessages), [
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toEqual([
currentMessages[0],
currentMessages[1],
])
@@ -85,6 +83,6 @@ describe('mergeLoadedSessionMessages', () => {
},
]
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toBe(storedMessages)
})
})
@@ -1,3 +1,5 @@
import type { SparkNotifyMessageOverride } from '@proj-airi/core-agent/agents/spark-notify'
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { UserMessage } from '@xsai/shared-chat'
@@ -11,6 +13,7 @@ import { defineStore, storeToRefs } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { getEventSourceKey } from '../../../utils/event-source'
import { useCharacterOrchestratorStore } from '../../character'
import { useChatOrchestratorStore } from '../../chat'
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME } from '../../chat/constants'
import { useChatContextStore } from '../../chat/context-store'
@@ -49,22 +52,125 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
const chatContext = useChatContextStore()
const serverChannelStore = useModsServerChannelStore()
const contextObservability = useContextObservabilityStore()
const characterOrchestratorStore = useCharacterOrchestratorStore()
const consciousnessStore = useConsciousnessStore()
const providersStore = useProvidersStore()
const { activeProvider, activeModel } = storeToRefs(consciousnessStore)
const { post: broadcastContext, data: incomingContext } = useBroadcastChannel<ContextMessage, ContextMessage>({ name: CONTEXT_CHANNEL_NAME })
const { post: broadcastStreamEvent, data: incomingStreamEvent } = useBroadcastChannel<ChatStreamEvent, ChatStreamEvent>({ name: CHAT_STREAM_CHANNEL_NAME })
interface SparkNotifyReactionOptions {
headline: string
fallbackText: string
note?: string
payload?: Record<string, unknown>
metadata?: Record<string, unknown>
lane?: string
kind?: 'alarm' | 'ping' | 'reminder'
urgency?: 'immediate' | 'soon' | 'later'
destinations?: string[]
source?: string
ttlMs?: number
requiresAck?: boolean
forceResponse?: boolean
forceTextResponse?: boolean
forceSparkCommandResponse?: boolean
messageOverride?: SparkNotifyMessageOverride
}
type SparkNotifyBridgeMessage
= | {
type: 'request'
requestId: string
fromInstanceId: string
payload: SparkNotifyReactionOptions
}
| {
type: 'response'
requestId: string
toInstanceId: string
reaction: string
}
const SPARK_NOTIFY_BRIDGE_CHANNEL_NAME = 'airi-spark-notify-bridge'
const sparkNotifyBridgeInstanceId = `spark-notify-${nanoid()}`
const sparkNotifyHostRole = ref<'main' | 'client'>('client')
const sparkNotifyBridgeWaiters = new Map<string, {
resolve: (reaction: string) => void
timeout: ReturnType<typeof setTimeout>
}>()
const { post: postSparkNotifyBridgeMessage, data: incomingSparkNotifyBridgeMessage } = useBroadcastChannel<SparkNotifyBridgeMessage, SparkNotifyBridgeMessage>({ name: SPARK_NOTIFY_BRIDGE_CHANNEL_NAME })
const disposeHookFns = ref<Array<() => void>>([])
let remoteStreamGuard: { sessionId: string, generation: number } | null = null
let initialized = false
async function handleSparkNotifyReactionLocal(options: SparkNotifyReactionOptions) {
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: options.source ?? 'plugin-module-host',
data: {
id: nanoid(),
eventId: nanoid(),
lane: options.lane,
kind: options.kind ?? 'ping',
urgency: options.urgency ?? 'immediate',
headline: options.headline,
note: options.note,
payload: options.payload,
ttlMs: options.ttlMs,
requiresAck: options.requiresAck,
destinations: options.destinations?.length ? options.destinations : ['character'],
metadata: options.metadata,
},
}
try {
return await characterOrchestratorStore.handleSparkNotifyWithReaction(event, {
fallbackText: options.fallbackText,
forceResponse: options.forceResponse,
forceTextResponse: options.forceTextResponse,
forceSparkCommandResponse: options.forceSparkCommandResponse,
messageOverride: options.messageOverride,
})
}
catch (error) {
console.warn('[context-bridge] spark:notify handling failed; using fallback', error)
return options.fallbackText
}
}
function setSparkNotifyHostRole(role: 'main' | 'client') {
sparkNotifyHostRole.value = role
}
async function dispatchSparkNotifyReaction(options: SparkNotifyReactionOptions) {
if (sparkNotifyHostRole.value === 'main') {
return await handleSparkNotifyReactionLocal(options)
}
const requestId = nanoid()
return await new Promise<string>((resolve) => {
const timeout = setTimeout(() => {
sparkNotifyBridgeWaiters.delete(requestId)
resolve(options.fallbackText)
}, 5000)
sparkNotifyBridgeWaiters.set(requestId, {
resolve: (reaction) => {
clearTimeout(timeout)
resolve(reaction || options.fallbackText)
},
timeout,
})
postSparkNotifyBridgeMessage({
type: 'request',
requestId,
fromInstanceId: sparkNotifyBridgeInstanceId,
payload: options,
})
})
}
async function initialize() {
await mutex.acquire()
@@ -130,6 +236,42 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
})
disposeHookFns.value.push(stop)
const { stop: stopSparkNotifyBridgeWatch } = watch(incomingSparkNotifyBridgeMessage, async (event) => {
if (!event) {
return
}
if (event.type === 'request') {
if (sparkNotifyHostRole.value !== 'main' || event.fromInstanceId === sparkNotifyBridgeInstanceId) {
return
}
const reaction = await handleSparkNotifyReactionLocal(event.payload)
postSparkNotifyBridgeMessage({
type: 'response',
requestId: event.requestId,
toInstanceId: event.fromInstanceId,
reaction,
})
return
}
if (event.type === 'response') {
if (event.toInstanceId !== sparkNotifyBridgeInstanceId) {
return
}
const waiter = sparkNotifyBridgeWaiters.get(event.requestId)
if (!waiter) {
return
}
sparkNotifyBridgeWaiters.delete(event.requestId)
waiter.resolve(event.reaction)
}
})
disposeHookFns.value.push(stopSparkNotifyBridgeWatch)
disposeHookFns.value.push(serverChannelStore.onContextUpdate((event) => {
contextObservability.recordLifecycle({
phase: 'server-received',
@@ -519,6 +661,11 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
initialized = false
remoteStreamGuard = null
for (const [requestId, waiter] of sparkNotifyBridgeWaiters) {
clearTimeout(waiter.timeout)
sparkNotifyBridgeWaiters.delete(requestId)
}
}
finally {
mutex.release()
@@ -530,6 +677,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
return {
initialize,
dispose,
dispatchSparkNotifyReaction,
setSparkNotifyHostRole,
}
})
@@ -1,8 +1,8 @@
export type {
CreateSparkNotifyToolsOptions,
SparkNotifyCommandDraft,
SparkNotifyCommandSchema,
} from '@proj-airi/core-agent/agents/spark-notify'
export {
createSparkNotifyTools,
sparkNotifyCommandSchema,