refactor(stage-ui,core-agent,stage-tamagotchi): better structure for llm, agent, and mini-agent

This commit is contained in:
Neko Ayaka
2026-08-09 04:05:38 +08:00
parent 38a008500d
commit 98fa1f0855
49 changed files with 691 additions and 923 deletions
@@ -1,6 +1,6 @@
import type { Tool } from '@xsai/shared-chat'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -1,7 +1,7 @@
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/llm-tools'
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import type { ChatToolReference } from '@proj-airi/stage-ui/types/chat'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { defineStore } from 'pinia'
import { imageJournalTools } from './builtin/image-journal'
@@ -1,6 +1,6 @@
import type { Tool } from '@xsai/shared-chat'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -1,7 +1,7 @@
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/llm-tools'
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { createMcpTools } from '@proj-airi/stage-ui/tools/mcp'
import { defineStore } from 'pinia'
@@ -1,7 +1,7 @@
import type { Tool } from '@xsai/shared-chat'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/llm-toolset-prompts'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/toolset-prompts'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -1,9 +1,9 @@
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/llm-tools'
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/llm-toolset-prompts'
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/toolset-prompts'
import { rawTool } from '@xsai/tool'
import { defineStore } from 'pinia'
@@ -0,0 +1,84 @@
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { SparkNotifyRunRequest } from './types'
import { describe, expect, it, vi } from 'vitest'
import { createSparkNotifyAgent } from './agent'
import { createSparkNotifyObserverPlugin, createSparkNotifyReactionPlugin } from './plugins'
function createEvent(): WebSocketEventOf<'spark:notify'> {
return {
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'],
},
}
}
describe('createSparkNotifyAgent', () => {
it('runs the selected chat and sends reaction text through a plugin', async () => {
const onDelta = vi.fn()
const onEnd = vi.fn()
const observedEvents: string[] = []
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.' })
})
const agent = createSparkNotifyAgent({
runner: { run },
plugins: [
createSparkNotifyReactionPlugin({ onDelta, onEnd }),
createSparkNotifyObserverPlugin((event) => {
observedEvents.push(event.type)
}),
],
createId: () => 'generated-id',
})
const result = await agent.handle({
event: createEvent(),
selectedChat: {
providerId: 'mock-provider',
model: 'mock-model',
provider: {} as ChatProvider,
},
systemPrompt: 'You are a character.',
})
expect(result.commands).toEqual([])
expect(onDelta).toHaveBeenCalledWith('spark-1', 'Checkmate.')
expect(onEnd).toHaveBeenCalledWith('spark-1', 'Checkmate.')
expect(observedEvents).toContain('model-output-text')
expect(observedEvents).toContain('result')
})
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.' })
})
const agent = createSparkNotifyAgent({ runner: { run } })
await agent.handle({
event: createEvent(),
selectedChat: {
providerId: 'mock-provider',
model: 'mock-model',
provider: {} as ChatProvider,
},
systemPrompt: 'You are a character.',
control: { forceTextResponse: true },
})
expect(run).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,274 @@
import type { ProtocolEvents } from '@proj-airi/plugin-protocol/types'
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
import type { Message, ToolChoice } from '@xsai/shared-chat'
import type { SparkNotifyCommandDraft } from './tools'
import type {
SparkNotifyPlugin,
SparkNotifyPluginSession,
SparkNotifyResponseControl,
SparkNotifyRunner,
SparkNotifyRuntimeEvent,
SparkNotifyRuntimePolicy,
SparkNotifySelectedChat,
} from './types'
import { nanoid } from 'nanoid'
import { getEventSourceKey } from './event-source'
import { createSparkNotifyBuiltinToolsPlugin } from './plugins/builtin-tools'
/**
* Final `spark:command` payload emitted by the notify runtime.
*
* The protocol allows `eventId` and `parentEventId` to be absent. This runtime
* generates both IDs for every emitted command, so they are required here.
*/
export type SparkNotifyCommandEvent = Pick<
ProtocolEvents['spark:command'],
| 'id'
| 'commandId'
| 'interrupt'
| 'priority'
| 'intent'
| 'ack'
| 'guidance'
| 'contexts'
| 'destinations'
> & Required<Pick<ProtocolEvents['spark:command'], 'eventId' | 'parentEventId'>>
/** 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.
*
* The host resolves model selection and schedules work. This agent only
* prepares and runs one notify turn.
*/
export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions): SparkNotifyAgent {
const createId = options.createId ?? nanoid
const plugins = [createSparkNotifyBuiltinToolsPlugin(), ...(options.plugins ?? [])]
async function handle(request: SparkNotifyHandleRequest): Promise<SparkNotifyHandleResult> {
const policy = resolveSparkNotifyRuntimePolicy(request.control)
const preparedSessions = await Promise.all(
plugins.map((plugin) => {
return plugin.prepare({
event: request.event,
selectedChat: request.selectedChat,
systemPrompt: request.systemPrompt,
control: request.control,
policy,
})
}),
)
const sessions = preparedSessions.filter((session): session is SparkNotifyPluginSession => session !== undefined)
const systemInstructions = sessions.flatMap(session => session.systemInstructions ?? [])
const userSections = sessions.flatMap(session => session.userSections ?? [])
const tools = policy.supportsTools
? sessions.flatMap(session => session.tools ?? [])
: []
const messages: Message[] = [
{
role: 'system',
content: [
request.systemPrompt,
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(request.event)),
...(request.control?.messageOverride?.appendSystemInstructions ?? []),
...systemInstructions,
].filter(Boolean).join('\n\n'),
},
{
role: 'user',
content: renderSparkNotifyUserMessage(request, userSections),
},
]
async function emit(event: SparkNotifyRuntimeEvent) {
for (const session of sessions)
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 } })
let reaction = ''
await options.runner.run({
selectedChat: request.selectedChat,
messages,
tools,
policy,
onStreamEvent: async (streamEvent) => {
if (streamEvent.type === 'text-delta') {
const { noResponse } = resultFrom(sessions)
if (policy.ignoreTextOutput || noResponse)
return
reaction += streamEvent.text
await emit({ type: 'model-output-text', payload: { eventId: request.event.data.id, text: streamEvent.text, accumulatedText: reaction } })
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 } })
return
}
if (streamEvent.type === 'tool-result') {
await emit({ type: 'tool-execution', payload: { eventId: request.event.data.eventId, toolCallId: streamEvent.toolCallId, output: streamEvent.result } })
return
}
if (streamEvent.type === 'error')
throw streamEvent.error ?? new Error('Spark notify stream error')
},
})
for (const session of sessions) {
for (const event of session.getPendingEvents?.() ?? [])
await emit(event)
}
const { commands, noResponse } = resultFrom(sessions)
const finalReaction = noResponse ? '' : reaction.trim()
const expandedCommands = commands
.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 } })
return { commands: expandedCommands }
}
return { handle }
}
@@ -16,17 +16,6 @@ function formatMetadataSource(source?: MetadataEventSource) {
return source.id
}
/**
* Resolves a stable source key for websocket-originated events.
*
* Before:
* - `{ source: "minecraft" }`
* - `{ metadata: { source: { extension: { id: "p" }, id: "i" } } }`
*
* After:
* - `"minecraft"`
* - `"p:i"`
*/
export function getEventSourceKey(event: EventSourcePayload, fallback = 'unknown') {
return (
formatMetadataSource(event.metadata?.source)
@@ -1,149 +0,0 @@
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,569 +0,0 @@
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, 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[]
}
/**
* 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?: 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
| EmbedProvider
| EmbedProviderWithExtraOptions
| SpeechProvider
| SpeechProviderWithExtraOptions
| TranscriptionProvider
| 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
}
/**
* Builds the instruction block prepended to Spark Notify agent prompts.
*
* Use when:
* - Handling `spark:notify` events
* - Constructing the per-turn system instruction for the notify reaction agent
*
* Expects:
* - `moduleName` resolved from event source metadata
*
* Returns:
* - Multiline instruction text for system prompt composition
*/
export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
return [
'This is AIRI system, the life pod hosting your consciousness. You don\'t need to respond to me or every spark:notify event directly.',
`Another module "${moduleName}" triggered spark:notify event for you to checkout.`,
'You may call the built-in tool "builtIn_sparkCommand" to issue spark:command to sub-agents as needed.',
'For any of the output that is not a tool call, it will be streamed to user\'s interface and maybe processed with text to speech system ',
'to be played out loud as your actual reaction to the spark:notify event.',
].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 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, control)` function that applies queue/processing policy and returns generated commands
*
* Call stack:
*
* `handle`
* -> `runNotifyAgent`
* -> `createSparkNotifyTools`
* -> `deps.stream`
* -> `deps.onReactionDelta`/`deps.onReactionEnd`
*/
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
handle: (event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) => Promise<SparkNotifyHandleResult | undefined>
} {
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
const activeProvider = deps.getActiveProvider()
const activeModel = deps.getActiveModel()
if (!activeProvider || !activeModel) {
console.warn('Spark notify ignored: missing active provider or model')
return undefined
}
const runtimePolicy = resolveSparkNotifyRuntimePolicy(control)
const chatProvider = await deps.getProviderInstance<ChatProvider>(activeProvider)
const commandDrafts: SparkNotifyCommandDraft[] = []
let noResponse = false
const { tools } = await createSparkNotifyTools({
onNoResponse: () => {
noResponse = true
},
onCommands: commands => commandDrafts.push(...commands),
onTrace: deps.onTrace,
allowNoResponse: runtimePolicy.allowNoResponse,
allowSparkCommand: runtimePolicy.allowSparkCommand,
})
const systemMessage: Message = {
role: 'system',
content: [
deps.getSystemPrompt(),
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
...(control?.messageOverride?.appendSystemInstructions ?? []),
].filter(Boolean).join('\n\n'),
}
const userMessage: Message = {
role: 'user',
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, messages, {
tools,
supportsTools: runtimePolicy.supportsTools,
waitForTools: runtimePolicy.waitForTools,
toolChoice: runtimePolicy.toolChoice,
onStreamEvent: async (streamEvent: StreamEvent) => {
if (streamEvent.type === 'text-delta') {
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 = 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, '')
}
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')
}
},
})
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,
commands: commandDrafts,
} satisfies SparkNotifyResponse
}
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
}
if (deps.getProcessing()) {
deps.setPending([...deps.getPending(), event])
return undefined
}
deps.setProcessing(true)
try {
const response = await runNotifyAgent(event, control)
if (!response)
return undefined
const commands = (response.commands ?? [])
.map(command => ({
id: nanoid(),
eventId: nanoid(),
parentEventId: event.data.id,
commandId: nanoid(),
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: command.destinations ?? [],
} satisfies SparkNotifyCommandEvent))
.filter(command => command.destinations.length > 0)
return {
commands,
}
}
finally {
deps.setProcessing(false)
}
}
return {
handle,
}
}
@@ -1,14 +1,20 @@
export type {
SparkNotifyAgentDeps,
CreateSparkNotifyAgentOptions,
SparkNotifyAgent,
SparkNotifyCommandEvent,
SparkNotifyHandleRequest,
SparkNotifyHandleResult,
SparkNotifyResponse,
SparkTraceCapture,
} from './handler'
} from './agent'
export {
createSparkNotifyAgent,
getSparkNotifyHandlingAgentInstruction,
setupAgentSparkNotifyHandler,
} from './handler'
} from './agent'
export {
createSparkNotifyBuiltinToolsPlugin,
createSparkNotifyObserverPlugin,
createSparkNotifyReactionPlugin,
} from './plugins'
export type { SparkNotifyReactionSink, SparkNotifyRuntimeObserver } from './plugins'
export type { SparkNotifyCommandSchema } from './schema'
export {
sparkNotifyCommandItemSchema,
@@ -21,8 +27,14 @@ export type {
export { createSparkNotifyTools } from './tools'
export type {
SparkNotifyMessageOverride,
SparkNotifyPlugin,
SparkNotifyPluginResult,
SparkNotifyPluginSession,
SparkNotifyResponseControl,
SparkNotifyRunner,
SparkNotifyRunRequest,
SparkNotifyRuntimeEvent,
SparkNotifyRuntimePolicy,
SparkNotifyTracingHooks,
SparkTraceEvent,
SparkNotifySelectedChat,
SparkNotifyTurn,
} from './types'
@@ -0,0 +1,34 @@
import type { SparkNotifyCommandDraft } from '../tools'
import type { SparkNotifyPlugin, SparkNotifyRuntimeEvent } from '../types'
import { createSparkNotifyTools } from '../tools'
/**
* Adds the built-in no-response and Spark Command tools to each notify turn.
*/
export function createSparkNotifyBuiltinToolsPlugin(): SparkNotifyPlugin {
return {
name: 'spark-notify-builtins',
async prepare(turn) {
const commands: SparkNotifyCommandDraft[] = []
const events: SparkNotifyRuntimeEvent[] = []
let noResponse = false
const { tools } = await createSparkNotifyTools({
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),
}
},
}
}
@@ -0,0 +1,5 @@
export { createSparkNotifyBuiltinToolsPlugin } from './builtin-tools'
export { createSparkNotifyObserverPlugin } from './observer'
export type { SparkNotifyRuntimeObserver } from './observer'
export type { SparkNotifyReactionSink } from './reaction'
export { createSparkNotifyReactionPlugin } from './reaction'
@@ -0,0 +1,12 @@
import type { SparkNotifyPlugin, SparkNotifyRuntimeEvent } from '../types'
/** Receives ordered lifecycle events from one Spark Notify run. */
export type SparkNotifyRuntimeObserver = (event: SparkNotifyRuntimeEvent) => void | Promise<void>
/** Adds an observer for diagnostics, telemetry adapters, or test recorders. */
export function createSparkNotifyObserverPlugin(observer: SparkNotifyRuntimeObserver): SparkNotifyPlugin {
return {
name: 'spark-notify-observer',
prepare: () => ({ onEvent: observer }),
}
}
@@ -0,0 +1,33 @@
import type { SparkNotifyPlugin } from '../types'
/** Receives the text that is suitable for a character reaction. */
export interface SparkNotifyReactionSink {
/** Receives a visible text delta while the model streams its reaction. */
onDelta: (eventId: string, text: string) => void
/** Receives the final visible reaction after the model stream ends. */
onEnd: (eventId: string, text: string) => void
}
/** Sends model reaction text to a host-owned presentation sink. */
export function createSparkNotifyReactionPlugin(sink: SparkNotifyReactionSink): SparkNotifyPlugin {
return {
name: 'spark-notify-reaction',
prepare(turn) {
return {
onEvent(event) {
if (event.type === 'model-output-text') {
const text = typeof event.payload.text === 'string' ? event.payload.text : ''
if (text)
sink.onDelta(turn.event.data.id, text)
return
}
if (event.type === 'result') {
const reaction = typeof event.payload.reaction === 'string' ? event.payload.reaction : ''
sink.onEnd(turn.event.data.id, reaction)
}
},
}
},
}
}
@@ -1,20 +1,14 @@
import type { ContextUpdate } from '@proj-airi/server-sdk'
import type { Tool } from '@xsai/shared-chat'
import type {
SparkNotifyTracingHooks,
SparkTraceEvent,
} from './types'
import type { SparkNotifyRuntimeEvent } from './types'
import { errorMessageFrom } from '@moeru/std'
import { rawTool } from '@xsai/tool'
import { toJsonSchema, validate } from 'xsschema'
import { z } from 'zod'
import {
normalizeNullableAnyOf,
sparkNotifyCommandSchema,
} from './schema'
import { normalizeNullableAnyOf, sparkNotifyCommandSchema } from './schema'
/**
* Normalized `builtIn_sparkCommand` payload forwarded to downstream orchestrators.
@@ -66,8 +60,8 @@ export interface CreateSparkNotifyToolsOptions {
onCommands: (commands: SparkNotifyCommandDraft[]) => void
/** Receives the no-response signal emitted by `builtIn_sparkNoResponse`. */
onNoResponse: () => void
/** Receives trace events describing tool exposure and execution. */
onTrace?: SparkNotifyTracingHooks['onTrace']
/** 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. */
@@ -137,95 +131,42 @@ export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOpti
const tools: Tool[] = []
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)
if (allowNoResponse) {
const name = 'builtIn_sparkNoResponse'
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)
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.onNoResponse()
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, responseMode: 'no-response' } })
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,
return 'AIRI System: Acknowledged, no response or action will be processed.'
},
}))
}
if (allowSparkCommand) {
const name = 'builtIn_sparkCommand'
tools.push(rawTool({
name: 'builtIn_sparkCommand',
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) => {
execute: async (rawPayload, context) => {
options.onEvent?.({ type: 'model-output-tool-call', payload: { toolName: name, toolCallId: context?.toolCallId, rawPayload } })
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 } })
}
catch (error) {
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, ok: false, error: errorMessageFrom(error) } })
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
}
@@ -1,4 +1,9 @@
import type { ToolChoice } from '@xsai/shared-chat'
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool, ToolChoice } from '@xsai/shared-chat'
import type { StreamEvent } from '../../types/llm'
import type { SparkNotifyCommandDraft } from './tools'
/**
* Runtime-only prompt hints used to reshape how one spark event is serialized for the model.
@@ -87,7 +92,7 @@ export interface SparkNotifyResponseControl {
/**
* Trace event emitted by the spark-notify runtime.
*/
export interface SparkTraceEvent {
export interface SparkNotifyRuntimeEvent {
/** Trace event category describing which stage of the notify run emitted the payload. */
type:
| 'messages-rendered'
@@ -104,9 +109,79 @@ export interface SparkTraceEvent {
/**
* 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
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
}
/** 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
}
/**
@@ -49,6 +49,10 @@ devtools:
pages:
context-flow:
title: Context Flow
editor:
title: AIRI Editor
description: Open the standalone AIRI Editor window
button: Open Editor
lag-visualizer:
title: Lag Visualizer
performance-visualizer:
@@ -49,6 +49,10 @@ devtools:
pages:
context-flow:
title: Context Flow
editor:
title: AIRI 编辑器
description: 打开独立的 AIRI 编辑器窗口
button: 打开编辑器
lag-visualizer:
title: Lag 可视化
performance-visualizer:
@@ -1,8 +1,8 @@
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import { errorMessageFrom } from '@moeru/std'
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/ai/chat-llm/tool-resolver'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/llm-tool-resolver'
import { executeToolCallRerun } from '@proj-airi/stage-ui/stores/tool-call-rerun'
export interface ChatToolCallRerunEvent {
+1
View File
@@ -42,6 +42,7 @@
"./stores/settings": "./src/stores/settings/index.ts",
"./stores/modules/vision": "./src/stores/modules/vision/index.ts",
"./stores/mcp-tool-bridge": "./src/stores/mcp-tool-bridge.ts",
"./stores/ai/chat-llm/*": "./src/stores/ai/chat-llm/*.ts",
"./stores/*": "./src/stores/*.ts",
"./stores": "./src/stores/index.ts",
"./workers/vad": "./src/workers/vad/index.ts",
@@ -43,10 +43,10 @@ import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } fr
import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state'
import { createStageTtsSession } from '../../libs/speech/tts-session'
import { getSpeechBusContext, speechOutputGetPlaybackState } from '../../services/speech/bus'
import { useLlmStreamingControlStore } from '../../stores/ai/chat-llm/streaming-control'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
import { useBackgroundStore } from '../../stores/background'
import { useChatStore } from '../../stores/chat'
import { useLlmStreamingControlStore } from '../../stores/llm-streaming-control'
import { useAiriCardStore } from '../../stores/modules'
import { useSpeechStore } from '../../stores/modules/speech'
import { useProviderConfigStore } from '../../stores/providers/config'
@@ -12,7 +12,7 @@ vi.mock('pinia', async () => {
}
})
vi.mock('../../stores/llm', () => ({
vi.mock('../../stores/ai/chat-llm/llm', () => ({
useLLM: () => ({
stream,
}),
@@ -6,7 +6,7 @@ import type { VisionWorkloadId } from './use-vision-workloads'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useLLM } from '../../stores/llm'
import { useLLM } from '../../stores/ai/chat-llm/llm'
import { useVisionStore } from '../../stores/modules/vision'
import { useProviderStore } from '../../stores/providers/provider'
import { getVisionWorkload } from './use-vision-workloads'
@@ -1,13 +1,13 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool } from '@xsai/shared-chat'
import type { ExecutableTool } from './llm-tools'
import type { ExecutableTool } from './tools'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isToolRelatedError, useLLM } from './llm'
import { useLlmToolsStore } from './llm-tools'
import { useLlmToolsStore } from './tools'
const {
streamTextMock,
@@ -38,7 +38,7 @@ vi.mock('@xsai/shared-chat', () => ({
stepCountAtLeast: vi.fn(),
}))
vi.mock('../tools', () => ({
vi.mock('../../../tools', () => ({
mcp: mcpMock,
debug: debugMock,
createSparkCommandTool: createSparkCommandToolMock,
@@ -7,7 +7,7 @@ import { listModels } from '@xsai/model'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { resolveLlmTools } from './llm-tool-resolver'
import { resolveLlmTools } from './tool-resolver'
export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent'
export { isContentArrayRelatedError, isToolRelatedError } from '@proj-airi/core-agent'
@@ -11,7 +11,7 @@ const spanMock = vi.hoisted(() => ({
const startSpanMock = vi.hoisted(() => vi.fn(() => spanMock))
vi.mock('../composables/use-io-tracer', () => ({
vi.mock('../../../composables/use-io-tracer', () => ({
activeTurnSpan: shallowRef(undefined),
startSpan: startSpanMock,
}))
@@ -31,7 +31,7 @@ describe('useLlmStreamingControlStore', () => {
* expect(startSpan).toHaveBeenCalledWith('Streaming control dispatch', ...)
*/
it('records streaming control dispatch spans and call handler events', async () => {
const { useLlmStreamingControlStore } = await import('./llm-streaming-control')
const { useLlmStreamingControlStore } = await import('./streaming-control')
const store = useLlmStreamingControlStore()
const handler = vi.fn()
@@ -82,7 +82,7 @@ describe('useLlmStreamingControlStore', () => {
* expect(span.addEvent).toHaveBeenCalledWith(IOEvents.StreamingControlRejected, ...)
*/
it('records rejected streaming control dispatches', async () => {
const { useLlmStreamingControlStore } = await import('./llm-streaming-control')
const { useLlmStreamingControlStore } = await import('./streaming-control')
const store = useLlmStreamingControlStore()
await expect(store.dispatchWith('<|CALL []|>')).resolves.toBe(false)
@@ -8,7 +8,7 @@ import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { watch } from 'vue'
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
import { activeTurnSpan, startSpan } from '../../../composables/use-io-tracer'
interface RemoteCallMessage {
type: 'turn-call'
@@ -2,7 +2,7 @@ import type { Tool } from '@xsai/shared-chat'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveLlmTools, toolNameFrom } from './llm-tool-resolver'
import { resolveLlmTools, toolNameFrom } from './tool-resolver'
// The default (non-injected) web-search branch reads the module store and the
// tools barrel; mock both so the configured-gate + key-trim logic can be
@@ -12,12 +12,12 @@ const { createWebSearchToolsMock, useWebSearchStoreMock } = vi.hoisted(() => ({
useWebSearchStoreMock: vi.fn(),
}))
vi.mock('../tools', async (importOriginal) => {
vi.mock('../../../tools', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return { ...actual, createWebSearchTools: createWebSearchToolsMock }
})
vi.mock('./modules/web-search', () => ({
vi.mock('../../modules/web-search', () => ({
useWebSearchStore: useWebSearchStoreMock,
}))
@@ -4,10 +4,10 @@ import type { Tool } from '@xsai/shared-chat'
import { uniqBy } from 'es-toolkit'
import { createSparkCommandTool, createWebSearchTools, debug, mcp } from '../tools'
import { useLlmToolsStore } from './llm-tools'
import { useModsServerChannelStore } from './mods/api/channel-server'
import { useWebSearchStore } from './modules/web-search'
import { createSparkCommandTool, createWebSearchTools, debug, mcp } from '../../../tools'
import { useModsServerChannelStore } from '../../mods/api/channel-server'
import { useWebSearchStore } from '../../modules/web-search'
import { useLlmToolsStore } from './tools'
type ToolSource = Tool[] | (() => Promise<Tool[]>)
@@ -1,11 +1,11 @@
import type { Tool } from '@xsai/shared-chat'
import type { ExecutableTool, ToolDefinition } from './llm-tools'
import type { ExecutableTool, ToolDefinition } from './tools'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLlmToolsStore } from './llm-tools'
import { useLlmToolsStore } from './tools'
function createExecutableTool(id: string, name = id): ExecutableTool {
return {
@@ -1,7 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useLlmToolsetPromptsStore } from './llm-toolset-prompts'
import { useLlmToolsetPromptsStore } from './toolset-prompts'
describe('useLlmToolsetPromptsStore', () => {
beforeEach(() => {
@@ -7,7 +7,7 @@ import type { Mock } from 'vitest'
import type { UnwrapRef } from 'vue'
import type z from 'zod'
import type { StreamEvent } from '../../llm'
import type { StreamEvent } from '../../ai/chat-llm/llm'
import type { AiriCard } from '../../modules'
import { createTestingPinia } from '@pinia/testing'
@@ -18,7 +18,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sparkNotifyCommandSchema, useCharacterOrchestratorStore } from '.'
import { useCharacterStore } from '..'
import { useLLM } from '../../llm'
import { useLLM } from '../../ai/chat-llm/llm'
import { useAiriCardStore, useConsciousnessStore } from '../../modules'
import { useProviderStore } from '../../providers/provider'
@@ -1,19 +0,0 @@
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,12 +1,13 @@
import type { SparkNotifyResponseControl } from '@proj-airi/core-agent/agents/spark-notify'
import type { WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import { setupAgentSparkNotifyHandler } from '@proj-airi/core-agent/agents/spark-notify'
import { createSparkNotifyAgent, createSparkNotifyReactionPlugin } from '@proj-airi/core-agent/agents/spark-notify'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useCharacterNotebookStore, useCharacterStore } from '../'
import { useLLM } from '../../llm'
import { useLLM } from '../../ai/chat-llm/llm'
import { useModsServerChannelStore } from '../../mods/api/channel-server'
import { useConsciousnessStore } from '../../modules/consciousness'
import { useProviderStore } from '../../providers/provider'
@@ -24,6 +25,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
const processing = ref(false)
const pendingNotifies = ref<Array<WebSocketEventOf<'spark:notify'>>>([])
const scheduledNotifies = ref<Array<{
event: WebSocketEventOf<'spark:notify'>
control?: SparkNotifyResponseControl
@@ -33,27 +35,38 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
maxAttempts: number
reason?: string
}>>([])
const attentionConfig = ref({
tickIntervalMs: 2_000,
taskNotifyWindowMs: 60_000,
requeueDelayMs: 30_000,
maxAttempts: 3,
})
let tickTimer: ReturnType<typeof setInterval> | undefined
let initialized = false
const eventUnsubscribes: Array<() => void> = []
const sparkNotifyAgent = setupAgentSparkNotifyHandler({
stream,
getActiveProvider: () => activeProvider.value,
getActiveModel: () => activeModel.value,
getProviderInstance: name => providersStore.getProviderInstance(name),
onReactionDelta: (eventId, text) => characterStore.onSparkNotifyReactionStreamEvent(eventId, text),
onReactionEnd: (eventId, text) => characterStore.onSparkNotifyReactionStreamEnd(eventId, text),
getSystemPrompt: () => systemPrompt.value,
getProcessing: () => processing.value,
setProcessing: next => processing.value = next,
getPending: () => pendingNotifies.value,
setPending: next => pendingNotifies.value = next,
const sparkNotifyAgent = createSparkNotifyAgent({
runner: {
run: request => stream(
request.selectedChat.model,
request.selectedChat.provider,
request.messages,
{
tools: request.tools,
supportsTools: request.policy.supportsTools,
waitForTools: request.policy.waitForTools,
toolChoice: request.policy.toolChoice,
onStreamEvent: request.onStreamEvent,
},
),
},
plugins: [
createSparkNotifyReactionPlugin({
onDelta: (eventId, text) => characterStore.onSparkNotifyReactionStreamEvent(eventId, text),
onEnd: (eventId, text) => characterStore.onSparkNotifyReactionStreamEnd(eventId, text),
}),
],
})
function computeNextRunAt(event: WebSocketEventOf<'spark:notify'>, attempts: number) {
@@ -103,18 +116,42 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
}
async function processSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
const result = await sparkNotifyAgent.handle(event, control)
if (!result?.commands?.length)
return result
for (const command of result.commands) {
modsServerChannelStore.send({
type: 'spark:command',
data: command as WebSocketEvents['spark:command'],
})
const providerId = activeProvider.value
const model = activeModel.value
if (!providerId || !model) {
console.warn('Spark notify ignored: missing active provider or model')
return undefined
}
return result
const provider = await providersStore.getProviderInstance<ChatProvider>(providerId)
processing.value = true
try {
const result = await sparkNotifyAgent.handle({
event,
selectedChat: {
providerId,
model,
provider,
},
systemPrompt: systemPrompt.value,
control,
})
if (!result.commands.length)
return result
for (const command of result.commands) {
modsServerChannelStore.send({
type: 'spark:command',
data: command,
})
}
return result
}
finally {
processing.value = false
}
}
async function handleIncomingSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
@@ -162,13 +162,13 @@ vi.mock('./chat/stream-store', () => ({
}),
}))
vi.mock('./llm', () => ({
vi.mock('./ai/chat-llm/llm', () => ({
useLLM: () => ({
stream: llmStreamMock,
}),
}))
vi.mock('./llm-tools', () => ({
vi.mock('./ai/chat-llm/tools', () => ({
useLlmToolsStore: () => ({
getToolsByNames: (...names: string[]) => getToolsByNamesMock(names),
}),
@@ -180,7 +180,7 @@ vi.mock('./providers/provider', () => ({
}),
}))
vi.mock('./llm-toolset-prompts', () => ({
vi.mock('./ai/chat-llm/toolset-prompts', () => ({
useLlmToolsetPromptsStore: () => ({
activeToolsetPrompt: 'Plugin toolset guidance.',
}),
+4 -4
View File
@@ -22,15 +22,15 @@ import {
} from '../libs/analytics-headers'
import { createChatAnalyticsHooks, getProviderMode } from '../libs/analytics/events/chat'
import { extractMessageText, isCloudSyncableMessage } from '../libs/chat-sync'
import { useLLM } from './ai/chat-llm/llm'
import { resolveLlmTools } from './ai/chat-llm/tool-resolver'
import { useLlmToolsStore } from './ai/chat-llm/tools'
import { useLlmToolsetPromptsStore } from './ai/chat-llm/toolset-prompts'
import { createMinecraftContext } from './chat/context-providers'
import { useChatContextStore } from './chat/context-store'
import { useChatSessionStore } from './chat/session-store'
import { useChatStreamStore } from './chat/stream-store'
import { useContextObservabilityStore } from './devtools/context-observability'
import { useLLM } from './llm'
import { resolveLlmTools } from './llm-tool-resolver'
import { useLlmToolsStore } from './llm-tools'
import { useLlmToolsetPromptsStore } from './llm-toolset-prompts'
import { useAiriCardStore } from './modules/airi-card'
import { useAutonomousArtistryStore } from './modules/artistry-autonomous'
import { useConsciousnessStore } from './modules/consciousness'
@@ -1,14 +1,14 @@
import type { TraceEvent } from '@proj-airi/stage-shared'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { StreamEvent } from './llm'
import type { StreamEvent } from './ai/chat-llm/llm'
import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-shared'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useLLM } from './ai/chat-llm/llm'
import { useChatStore } from './chat'
import { useLLM } from './llm'
import { useConsciousnessStore } from './modules/consciousness'
import { usePerfTracerBridgeStore } from './perf-tracer-bridge'
import { useProviderStore } from './providers/provider'
@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLlmStreamingControlStore } from '../../llm-streaming-control'
import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control'
import { useContextBridgeStore } from './context-bridge'
type SparkNotifyReactionMock = (event: {
@@ -15,13 +15,13 @@ import { defineStore, storeToRefs } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { getEventSourceKey, getMetadataSourceLabel } from '../../../utils/event-source'
import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control'
import { useCharacterOrchestratorStore } from '../../character'
import { useChatStore } from '../../chat'
import { useChatContextStore } from '../../chat/context-store'
import { useChatSessionStore } from '../../chat/session-store'
import { useChatStreamStore } from '../../chat/stream-store'
import { useContextObservabilityStore } from '../../devtools/context-observability'
import { useLlmStreamingControlStore } from '../../llm-streaming-control'
import { useConsciousnessStore } from '../../modules/consciousness'
import { useProviderStore } from '../../providers/provider'
import { useModsServerChannelStore } from './channel-server'
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { WEB_SEARCH_TOOLSET_PROMPT } from '../../tools/web-search'
import { useLlmToolsetPromptsStore } from '../llm-toolset-prompts'
import { useLlmToolsetPromptsStore } from '../ai/chat-llm/toolset-prompts'
import { useWebSearchStore } from './web-search'
describe('useWebSearchStore', () => {
@@ -3,14 +3,14 @@ import { defineStore } from 'pinia'
import { computed, watch } from 'vue'
import { WEB_SEARCH_TOOLSET_PROMPT } from '../../tools/web-search'
import { useLlmToolsetPromptsStore } from '../llm-toolset-prompts'
import { useLlmToolsetPromptsStore } from '../ai/chat-llm/toolset-prompts'
/**
* Settings + lifecycle for the web-search capability (Tavily-backed).
*
* Renderer-only: unlike the messaging modules it does not broadcast to a backend
* service, so there is no configurator channel here. The tool itself is mounted
* by `resolveWebSearchTools` in `stores/llm-tool-resolver.ts`, gated on
* by `resolveWebSearchTools` in `stores/ai/chat-llm/tool-resolver.ts`, gated on
* {@link configured}; this store owns the paired system-prompt guidance so the
* "web content is data, not instructions" rule is present exactly when the tool
* is, and gone when it is not.
@@ -4,7 +4,7 @@ import type { ChatAssistantMessage, ChatHistoryItem, ChatSlicesToolCallResult }
import { errorMessageFrom } from '@moeru/std'
import { toolNameFrom } from './llm-tool-resolver'
import { toolNameFrom } from './ai/chat-llm/tool-resolver'
export interface ToolCallRerunPayload<TToolset extends string = string> {
sessionId?: string
@@ -303,7 +303,7 @@ describe('tools/character/orchestrator/spark-command', () => {
})
it('reports a broadcast without crashing when the channel sender clears destinations', async () => {
// The real sendSparkCommand (stores/llm.ts) deletes command.destinations to broadcast to every
// The real sendSparkCommand (stores/ai/chat-llm/llm.ts) deletes command.destinations to broadcast to every
// authenticated peer; the success message must not then call .join on undefined.
const sendSparkCommand = vi.fn((command: { destinations?: unknown }) => {
delete command.destinations
@@ -65,7 +65,7 @@ export async function createSparkCommandTool(options: CreateSparkCommandToolOpti
options.sendSparkCommand(command)
// `destinations` may be undefined: the channel sender (stores/llm.ts sendSparkCommand) deletes
// `destinations` may be undefined: the channel sender (stores/ai/chat-llm/llm.ts sendSparkCommand) deletes
// it to trigger broadcast-to-all-authenticated-peers. Guard the .join so we don't surface
// "Cannot read properties of undefined (reading 'join')" back to the LLM after a successful send.
const dests = Array.isArray(command.destinations) && command.destinations.length > 0
+1 -1
View File
@@ -196,7 +196,7 @@ function formatResults(query: string, results: SearchResult[]): string {
* Only mount this when an API key is configured a search with no key can only
* ever error, so callers gate on the web-search module's `configured` state and
* simply omit the tool otherwise (see `resolveWebSearchTools` in
* `stores/llm-tool-resolver.ts`). The returned tool reads the web on the model's
* `stores/ai/chat-llm/tool-resolver.ts`). The returned tool reads the web on the model's
* behalf; results are wrapped as untrusted content and must be paired with
* {@link WEB_SEARCH_TOOLSET_PROMPT} in the system prompt.
*