refactor(core-agent,stage-ui): move spark-notify agent to core-agent

This commit is contained in:
Neko Ayaka
2026-04-17 16:18:42 +08:00
parent 8d6905481b
commit 70495439ad
12 changed files with 575 additions and 296 deletions
+11 -1
View File
@@ -19,6 +19,10 @@
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./agents/spark-notify": {
"types": "./dist/agents/spark-notify/index.d.mts",
"default": "./dist/agents/spark-notify/index.mjs"
}
},
"main": "./dist/index.mjs",
@@ -33,11 +37,17 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@moeru/std": "catalog:",
"@proj-airi/server-sdk": "workspace:^",
"@proj-airi/server-shared": "workspace:^",
"@xsai-ext/providers": "catalog:",
"@xsai/model": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:"
"@xsai/stream-text": "catalog:",
"@xsai/tool": "catalog:",
"nanoid": "^5.1.7",
"xsschema": "catalog:",
"zod": "^4.3.6"
},
"devDependencies": {
"tsdown": "catalog:",
@@ -0,0 +1,35 @@
import type { MetadataEventSource } from '@proj-airi/server-sdk'
interface EventSourcePayload {
source?: string
metadata?: { source?: MetadataEventSource }
}
function formatMetadataSource(source?: MetadataEventSource) {
if (!source?.plugin)
return undefined
const pluginId = source.plugin.id
const instanceId = source.id
return instanceId ? `${pluginId}:${instanceId}` : pluginId
}
/**
* Resolves a stable source key for websocket-originated events.
*
* Before:
* - `{ source: "minecraft" }`
* - `{ metadata: { source: { plugin: { id: "p" }, id: "i" } } }`
*
* After:
* - `"minecraft"`
* - `"p:i"`
*/
export function getEventSourceKey(event: EventSourcePayload, fallback = 'unknown') {
return (
formatMetadataSource(event.metadata?.source)
?? event.source
?? fallback
)
}
@@ -0,0 +1,235 @@
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 { StreamEvent } from '../../types/llm'
import type { SparkNotifyCommandDraft } from './tools'
import { nanoid } from 'nanoid'
import { getEventSourceKey } from './event-source'
import { createSparkNotifyTools } from './tools'
export interface SparkNotifyResponse {
reaction?: string
commands?: SparkNotifyCommandDraft[]
}
export interface SparkNotifyCommandEvent {
id: string
eventId: string
parentEventId: string
commandId: string
interrupt: 'force' | 'soft' | false
priority: 'critical' | 'high' | 'normal' | 'low'
intent: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
ack?: string
guidance?: SparkNotifyCommandDraft['guidance']
contexts?: SparkNotifyCommandDraft['contexts']
destinations: string[]
}
export interface SparkNotifyHandleResult {
commands: SparkNotifyCommandEvent[]
}
export interface SparkNotifyAgentDeps {
stream: (
model: string,
provider: ChatProvider,
messages: Message[],
options: {
tools?: any[]
supportsTools?: boolean
waitForTools?: boolean
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
},
) => Promise<void>
getActiveProvider: () => string | undefined
getActiveModel: () => string | undefined
getProviderInstance: <R extends
| ChatProvider
| ChatProviderWithExtraOptions
| EmbedProvider
| EmbedProviderWithExtraOptions
| SpeechProvider
| SpeechProviderWithExtraOptions
| TranscriptionProvider
| TranscriptionProviderWithExtraOptions,
>(name: string,
) => Promise<R>
onReactionDelta: (eventId: string, text: string) => void
onReactionEnd: (eventId: string, text: string) => void
getSystemPrompt: () => string
getProcessing: () => boolean
setProcessing: (next: boolean) => void
getPending: () => Array<WebSocketEventOf<'spark:notify'>>
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')
}
/**
* 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
* - 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
*
* Call stack:
*
* `handle`
* -> `runNotifyAgent`
* -> `createSparkNotifyTools`
* -> `deps.stream`
* -> `deps.onReactionDelta`/`deps.onReactionEnd`
*/
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
handle: (event: WebSocketEventOf<'spark:notify'>) => Promise<SparkNotifyHandleResult | undefined>
} {
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
const activeProvider = deps.getActiveProvider()
const activeModel = deps.getActiveModel()
if (!activeProvider || !activeModel) {
console.warn('Spark notify ignored: missing active provider or model')
return undefined
}
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),
})
const systemMessage: Message = {
role: 'system',
content: [
deps.getSystemPrompt(),
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
].filter(Boolean).join('\n\n'),
}
const userMessage: Message = {
role: 'user',
content: JSON.stringify({
notify: event.data,
source: event.source,
}, null, 2),
}
let fullText = ''
await deps.stream(activeModel, chatProvider, [systemMessage, userMessage], {
tools,
supportsTools: true,
waitForTools: true,
onStreamEvent: async (streamEvent: StreamEvent) => {
if (streamEvent.type === 'text-delta') {
if (noResponse)
return
deps.onReactionDelta(event.data.id, streamEvent.text)
fullText += streamEvent.text
}
if (streamEvent.type === 'finish') {
if (noResponse) {
deps.onReactionEnd(event.data.id, '')
return
}
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')
}
},
})
return {
reaction: fullText.trim(),
commands: commandDrafts,
} satisfies SparkNotifyResponse
}
async function handle(event: WebSocketEventOf<'spark:notify'>): 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)
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,
}
}
@@ -0,0 +1,20 @@
export type {
SparkNotifyAgentDeps,
SparkNotifyCommandEvent,
SparkNotifyHandleResult,
SparkNotifyResponse,
} from './handler'
export {
getSparkNotifyHandlingAgentInstruction,
setupAgentSparkNotifyHandler,
} from './handler'
export type { SparkNotifyCommandSchema } from './schema'
export {
sparkNotifyCommandItemSchema,
sparkNotifyCommandSchema,
} from './schema'
export type {
CreateSparkNotifyToolsOptions,
SparkNotifyCommandDraft,
} from './tools'
export { createSparkNotifyTools } from './tools'
@@ -0,0 +1,102 @@
import type { JsonSchema } from 'xsschema'
import { z } from 'zod'
const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema {
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
}
/**
* Normalizes nullable scalar unions in generated JSON schema.
*
* Before:
* - `{ anyOf: [{ type: 'string' }, { type: 'null' }] }`
*
* After:
* - `{ type: ['string', 'null'] }`
*/
export function normalizeNullableAnyOf(schema: JsonSchema): JsonSchema {
// NOTICE: `xsschema` emits nullable unions using `anyOf`, but some OpenAI-compatible
// validators reject that shape while accepting `type: ['string', 'null']`.
const next: JsonSchema = { ...schema }
if (next.properties) {
next.properties = Object.fromEntries(
Object.entries(next.properties).map(([key, value]) => {
if (!isJsonSchema(value))
return [key, value]
return [key, normalizeNullableAnyOf(value)]
}),
)
}
if (Array.isArray(next.items)) {
next.items = next.items.map(item => isJsonSchema(item) ? normalizeNullableAnyOf(item) : item)
}
else if (isJsonSchema(next.items)) {
next.items = normalizeNullableAnyOf(next.items)
}
if (next.anyOf) {
next.anyOf = next.anyOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
const normalizedEntries = next.anyOf.filter(isJsonSchema)
const primitiveTypes = normalizedEntries
.map(entry => entry.type)
.filter((type): type is Exclude<JsonSchema['type'], JsonSchema['type'][]> => typeof type === 'string')
const dedupedPrimitiveTypes = [...new Set(primitiveTypes)]
if (
primitiveTypes.length === normalizedEntries.length
&& dedupedPrimitiveTypes.length > 0
&& dedupedPrimitiveTypes.every(type => JSON_SCHEMA_NULLABLE_SCALAR_TYPES.has(String(type)))
) {
delete next.anyOf
next.type = dedupedPrimitiveTypes as JsonSchema['type']
}
}
if (next.oneOf) {
next.oneOf = next.oneOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
}
return next
}
export const sparkCommandGuidanceOptionSchema = z.object({
label: z.string().describe('Short label for the option.'),
steps: z.array(z.string()).min(1).describe('Step-by-step actions the target should follow.'),
rationale: z.union([z.string(), z.null()]).describe('Why this option makes sense.'),
possibleOutcome: z.union([z.array(z.string()), z.null()]).describe('Expected outcomes if this option is followed.'),
risk: z.union([z.enum(['high', 'medium', 'low', 'none']), z.null()]).describe('Risk level of this option.'),
fallback: z.union([z.array(z.string()), z.null()]).describe('Fallback steps if the main plan fails.'),
triggers: z.union([z.array(z.string()), z.null()]).describe('Conditions that should trigger this option.'),
}).strict()
export const sparkCommandPersonaSchema = z.object({
traits: z.string().describe('Trait name to adjust behavior. For example, "bravery", "cautiousness", "friendliness".'),
strength: z.enum(['very-high', 'high', 'medium', 'low', 'very-low']),
}).strict()
export const sparkNotifyCommandGuidanceSchema = z.object({
type: z.enum(['proposal', 'instruction', 'memory-recall']),
persona: z.union([z.array(sparkCommandPersonaSchema), z.null()]).describe('Optional persona controls for the receiver.'),
options: z.array(sparkCommandGuidanceOptionSchema),
}).strict()
export const sparkNotifyCommandItemSchema = z.object({
destinations: z.array(z.string()).min(1).describe('List of sub-agent IDs to send the command to'),
interrupt: z.union([z.enum(['force', 'soft', 'false']), z.null()]).describe('Interrupt type: force, soft, or false (no interrupt).'),
priority: z.union([z.enum(['critical', 'high', 'normal', 'low']), z.null()]).describe('Semantic priority of the command.'),
intent: z.union([z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']), z.null()]).describe('Intent of the command.'),
ack: z.string().describe('Acknowledgment content used to be passed to sub-agents upon command receipt.'),
guidance: z.union([sparkNotifyCommandGuidanceSchema, z.null()]).describe('Guidance for the sub-agent on how to interpret and execute the command.'),
}).strict()
export const sparkNotifyCommandSchema = z.object({
commands: z.array(sparkNotifyCommandItemSchema).describe('List of commands to issue to sub-agents. Empty array can be used for zero commands.'),
}).strict()
export type SparkNotifyCommandSchema = z.infer<typeof sparkNotifyCommandSchema>
@@ -0,0 +1,132 @@
import type { ContextUpdate } from '@proj-airi/server-sdk'
import { errorMessageFrom } from '@moeru/std'
import { rawTool } from '@xsai/tool'
import { toJsonSchema, validate } from 'xsschema'
import { z } from 'zod'
import {
normalizeNullableAnyOf,
sparkNotifyCommandSchema,
} from './schema'
export interface SparkNotifyCommandDraft {
destinations: string[]
interrupt?: 'force' | 'soft' | boolean
priority?: 'critical' | 'high' | 'normal' | 'low'
intent?: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
ack?: string
guidance?: {
type: 'proposal' | 'instruction' | 'memory-recall'
persona?: Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>
options: Array<{
label: string
steps: string[]
rationale?: string
possibleOutcome?: string[]
risk?: 'high' | 'medium' | 'low' | 'none'
fallback?: string[]
triggers?: string[]
}>
}
contexts?: ContextUpdate<Record<string, unknown>, undefined>[]
}
export interface CreateSparkNotifyToolsOptions {
onCommands: (commands: SparkNotifyCommandDraft[]) => void
onNoResponse: () => void
}
/**
* Normalizes provider-facing notify command payloads into websocket draft commands.
*
* Use when:
* - LLM output has been validated against `sparkNotifyCommandSchema`
* - You need `spark:command`-compatible draft objects
*
* Expects:
* - Input shape from one `commands[]` entry
*
* Returns:
* - Runtime-ready command draft for downstream emitters
*/
function normalizeSparkNotifyCommand(
command: z.infer<typeof sparkNotifyCommandSchema>['commands'][number],
): SparkNotifyCommandDraft {
return {
destinations: command.destinations,
guidance: command.guidance
? {
type: command.guidance.type,
persona: command.guidance.persona?.reduce((acc, curr) => {
acc[curr.traits] = curr.strength
return acc
}, {} as Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>) || undefined,
options: command.guidance.options.map(option => ({
...option,
rationale: option.rationale ?? undefined,
possibleOutcome: option.possibleOutcome?.length ? option.possibleOutcome : undefined,
risk: option.risk ?? undefined,
fallback: option.fallback?.length ? option.fallback : undefined,
triggers: option.triggers?.length ? option.triggers : undefined,
})),
}
: undefined,
// TODO: contexts can be added later
contexts: [],
priority: command.priority || 'normal',
intent: command.intent || 'action',
ack: command.ack || undefined,
interrupt: command.interrupt === 'false' || command.interrupt == null ? false : command.interrupt,
}
}
/**
* Creates built-in tools used by the Spark Notify agent.
*
* Use when:
* - Running the Spark Notify agent on any runtime (web, desktop, eval harness)
* - You need "no response" and "command draft" tool pathways
*
* Expects:
* - Callbacks for command collection and no-response signaling
*
* Returns:
* - Tool array consumable by `@xsai/stream-text`
*/
export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOptions) {
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 () => {
options.onNoResponse()
return 'AIRI System: Acknowledged, no response or action will be processed.'
},
})
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) => {
try {
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
const validated = await validate(sparkNotifyCommandSchema, payload)
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
}
catch (error) {
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
}
return 'AIRI System: Acknowledged, command fired.'
},
})
return {
tools: [
sparkNoResponseTool,
sparkCommandTool,
],
}
}
+1 -3
View File
@@ -1,13 +1,11 @@
export type { AgentContextPort } from './contracts/context-port'
export type { ChatHookRegistry } from './contracts/hook-types'
export type { AgentLLMPort } from './contracts/llm-port'
export type { AgentSessionPort } from './contracts/session-port'
export type { AgentForegroundStreamPort } from './contracts/stream-port'
export { createChatHooks } from './runtime/agent-hooks'
export type { ContextHistoryEntry, ContextRegistry } from './runtime/context-registry'
export { createContextRegistry } from './runtime/context-registry'
export {
isToolRelatedError,
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown'
export default defineConfig({
entry: [
'src/index.ts',
'src/agents/spark-notify/index.ts',
],
dts: true,
})
@@ -1,183 +1,10 @@
import type { WebSocketEventOf, WebSocketEvents } 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 { SparkNotifyCommandDraft } from '../../../../../tools'
import type { StreamEvent } from '../../../../llm'
import { nanoid } from 'nanoid'
import { createSparkNotifyTools } from '../../../../../tools'
import { getEventSourceKey } from '../../../../../utils'
export type { SparkNotifyCommandDraft, SparkNotifyCommandSchema } from '../../../../../tools'
export { sparkNotifyCommandSchema } from '../../../../../tools'
export interface SparkNotifyResponse {
reaction?: string
commands?: SparkNotifyCommandDraft[]
}
export interface SparkNotifyAgentDeps {
stream: (
model: string,
provider: ChatProvider,
messages: Message[],
options: {
tools?: any[]
supportsTools?: boolean
waitForTools?: boolean
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
},
) => Promise<void>
getActiveProvider: () => string | undefined
getActiveModel: () => string | undefined
getProviderInstance: <R extends
| ChatProvider
| ChatProviderWithExtraOptions
| EmbedProvider
| EmbedProviderWithExtraOptions
| SpeechProvider
| SpeechProviderWithExtraOptions
| TranscriptionProvider
| TranscriptionProviderWithExtraOptions,
>(name: string,
) => Promise<R>
onReactionDelta: (eventId: string, text: string) => void
onReactionEnd: (eventId: string, text: string) => void
getSystemPrompt: () => string
getProcessing: () => boolean
setProcessing: (next: boolean) => void
getPending: () => Array<WebSocketEventOf<'spark:notify'>>
setPending: (next: Array<WebSocketEventOf<'spark:notify'>>) => void
}
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')
}
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
const activeProvider = deps.getActiveProvider()
const activeModel = deps.getActiveModel()
if (!activeProvider || !activeModel) {
console.warn('Spark notify ignored: missing active provider or model')
return undefined
}
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),
})
const systemMessage: Message = {
role: 'system',
content: [
deps.getSystemPrompt(),
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
].filter(Boolean).join('\n\n'),
}
const userMessage: Message = {
role: 'user',
content: JSON.stringify({
notify: event.data,
source: event.source,
}, null, 2),
}
let fullText = ''
await deps.stream(activeModel, chatProvider, [systemMessage, userMessage], {
tools,
supportsTools: true,
waitForTools: true,
onStreamEvent: async (streamEvent: StreamEvent) => {
if (streamEvent.type === 'text-delta') {
if (noResponse)
return
deps.onReactionDelta(event.data.id, streamEvent.text)
fullText += streamEvent.text
}
if (streamEvent.type === 'finish') {
if (noResponse) {
deps.onReactionEnd(event.data.id, '')
return
}
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')
}
},
})
return {
reaction: fullText.trim(),
commands: commandDrafts,
} satisfies SparkNotifyResponse
}
async function handle(event: WebSocketEventOf<'spark:notify'>) {
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)
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 WebSocketEvents['spark:command']))
.filter(command => command.destinations.length > 0)
return {
commands,
}
}
finally {
deps.setProcessing(false)
}
}
return {
handle,
}
}
export type {
SparkNotifyAgentDeps,
SparkNotifyCommandDraft,
SparkNotifyCommandSchema,
SparkNotifyResponse,
} from '@proj-airi/core-agent/agents/spark-notify'
export {
setupAgentSparkNotifyHandler,
sparkNotifyCommandSchema,
} from '@proj-airi/core-agent/agents/spark-notify'
@@ -99,7 +99,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
for (const command of result.commands) {
modsServerChannelStore.send({
type: 'spark:command',
data: command,
data: command as WebSocketEvents['spark:command'],
})
}
@@ -1,108 +1,9 @@
import type { WebSocketEvents } from '@proj-airi/server-sdk'
import { errorMessageFrom } from '@moeru/std'
import { rawTool } from '@xsai/tool'
import { toJsonSchema, validate } from 'xsschema'
import { z } from 'zod'
import {
normalizeNullableAnyOf,
sparkNotifyCommandItemSchema,
} from './spark-command-shared'
export interface SparkNotifyCommandDraft {
destinations: string[]
interrupt?: 'force' | 'soft' | boolean
priority?: 'critical' | 'high' | 'normal' | 'low'
intent?: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
ack?: string
guidance?: WebSocketEvents['spark:command']['guidance']
contexts?: WebSocketEvents['spark:command']['contexts']
}
export const sparkNotifyCommandSchema = z.object({
commands: z.array(sparkNotifyCommandItemSchema).describe('List of commands to issue to sub-agents, you may produce multiple commands in response to multiple sub-agents by specifying their IDs in destination field. Empty array can be used for zero commands.'),
}).strict()
export type SparkNotifyCommandSchema = z.infer<typeof sparkNotifyCommandSchema>
export interface CreateSparkNotifyToolsOptions {
onCommands: (commands: SparkNotifyCommandDraft[]) => void
onNoResponse: () => void
}
function normalizeSparkNotifyCommand(
command: z.infer<typeof sparkNotifyCommandSchema>['commands'][number],
): SparkNotifyCommandDraft {
// NOTICE: The notify-agent tool schema preserves the LLM-facing payload shape, but the
// orchestrator stores runtime drafts in the websocket event shape. This normalizes array
// persona entries and nullable guidance fields back into the draft shape expected downstream.
return {
destinations: command.destinations,
guidance: command.guidance
? {
type: command.guidance.type,
persona: command.guidance.persona?.reduce((acc, curr) => {
acc[curr.traits] = curr.strength
return acc
}, {} as Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>) || undefined,
options: command.guidance.options.map(option => ({
...option,
rationale: option.rationale ?? undefined,
possibleOutcome: option.possibleOutcome?.length ? option.possibleOutcome : undefined,
risk: option.risk ?? undefined,
fallback: option.fallback?.length ? option.fallback : undefined,
triggers: option.triggers?.length ? option.triggers : undefined,
})),
}
: undefined,
// TODO: contexts can be added later
contexts: [],
priority: command.priority || 'normal',
intent: command.intent || 'action',
ack: command.ack || undefined,
interrupt: command.interrupt === 'false' || command.interrupt == null ? false : command.interrupt,
}
}
export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOptions) {
const sparkNoResponseTool = rawTool({
name: 'builtIn_sparkNoResponse',
description: 'Indicate that no response or action is needed for the current spark:notify event.',
// NOTICE: Keep the same raw-tool + normalized-schema path as the general spark command
// tool so built-in notify tools do not regress on the same provider-specific schema checks.
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 sparkCommandTool = rawTool({
name: 'builtIn_sparkCommand',
description: 'Issue a spark:command to sub-agents. You can call this tool multiple times to issue matrices of commands to different sub-agents as needed.',
// NOTICE: `sparkNotifyCommandSchema` keeps the notify-agent input shape, but its emitted
// JSON Schema still passes through the shared nullable-union normalizer before rawTool
// freezes it for OpenAI-compatible providers.
parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any),
execute: async (rawPayload) => {
try {
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
const validated = await validate(sparkNotifyCommandSchema, payload)
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
}
catch (error) {
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
}
return 'AIRI System: Acknowledged, command fired.'
},
})
return {
tools: [
sparkNoResponseTool,
sparkCommandTool,
],
}
}
export type {
CreateSparkNotifyToolsOptions,
SparkNotifyCommandDraft,
SparkNotifyCommandSchema,
} from '@proj-airi/core-agent/agents/spark-notify'
export {
createSparkNotifyTools,
sparkNotifyCommandSchema,
} from '@proj-airi/core-agent/agents/spark-notify'
+18
View File
@@ -2359,6 +2359,12 @@ importers:
packages/core-agent:
dependencies:
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@proj-airi/server-sdk':
specifier: workspace:^
version: link:../server-sdk
'@proj-airi/server-shared':
specifier: workspace:^
version: link:../server-shared
@@ -2374,6 +2380,18 @@ importers:
'@xsai/stream-text':
specifier: 'catalog:'
version: 0.5.0-beta.2(patch_hash=90dfe10d02f5946658508ec019937eab600745c93446ce7b2fdb1a0ed70e3e49)
'@xsai/tool':
specifier: 'catalog:'
version: 0.5.0-beta.2(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
nanoid:
specifier: ^5.1.7
version: 5.1.7
xsschema:
specifier: 'catalog:'
version: 0.5.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.3.1(typescript@5.9.3)))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
tsdown:
specifier: 'catalog:'