diff --git a/packages/core-agent/package.json b/packages/core-agent/package.json index 4c92a55e1..7230e43b4 100644 --- a/packages/core-agent/package.json +++ b/packages/core-agent/package.json @@ -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:", diff --git a/packages/core-agent/src/agents/spark-notify/event-source.ts b/packages/core-agent/src/agents/spark-notify/event-source.ts new file mode 100644 index 000000000..36820ef30 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/event-source.ts @@ -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 + ) +} diff --git a/packages/core-agent/src/agents/spark-notify/handler.ts b/packages/core-agent/src/agents/spark-notify/handler.ts new file mode 100644 index 000000000..f3f53251e --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/handler.ts @@ -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 + }, + ) => Promise + getActiveProvider: () => string | undefined + getActiveModel: () => string | undefined + getProviderInstance: (name: string, + ) => Promise + onReactionDelta: (eventId: string, text: string) => void + onReactionEnd: (eventId: string, text: string) => void + getSystemPrompt: () => string + getProcessing: () => boolean + setProcessing: (next: boolean) => void + getPending: () => Array> + setPending: (next: Array>) => 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 +} { + 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(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 { + 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, + } +} diff --git a/packages/core-agent/src/agents/spark-notify/index.ts b/packages/core-agent/src/agents/spark-notify/index.ts new file mode 100644 index 000000000..322e2a728 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/index.ts @@ -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' diff --git a/packages/core-agent/src/agents/spark-notify/schema.ts b/packages/core-agent/src/agents/spark-notify/schema.ts new file mode 100644 index 000000000..c9edcbf04 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/schema.ts @@ -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 => 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 diff --git a/packages/core-agent/src/agents/spark-notify/tools.ts b/packages/core-agent/src/agents/spark-notify/tools.ts new file mode 100644 index 000000000..b620bb973 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/tools.ts @@ -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 + options: Array<{ + label: string + steps: string[] + rationale?: string + possibleOutcome?: string[] + risk?: 'high' | 'medium' | 'low' | 'none' + fallback?: string[] + triggers?: string[] + }> + } + contexts?: ContextUpdate, 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['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) || 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 + 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, + ], + } +} diff --git a/packages/core-agent/src/index.ts b/packages/core-agent/src/index.ts index 5eb9cc10f..8ec6dcdf8 100644 --- a/packages/core-agent/src/index.ts +++ b/packages/core-agent/src/index.ts @@ -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, diff --git a/packages/core-agent/tsdown.config.ts b/packages/core-agent/tsdown.config.ts index 6671ca3a4..3701a3a8c 100644 --- a/packages/core-agent/tsdown.config.ts +++ b/packages/core-agent/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ entry: [ 'src/index.ts', + 'src/agents/spark-notify/index.ts', ], dts: true, }) diff --git a/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts b/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts index 24bcf90ea..ed84d39b6 100644 --- a/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts +++ b/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts @@ -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 - }, - ) => Promise - getActiveProvider: () => string | undefined - getActiveModel: () => string | undefined - getProviderInstance: (name: string, - ) => Promise - onReactionDelta: (eventId: string, text: string) => void - onReactionEnd: (eventId: string, text: string) => void - getSystemPrompt: () => string - getProcessing: () => boolean - setProcessing: (next: boolean) => void - getPending: () => Array> - setPending: (next: Array>) => 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(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' diff --git a/packages/stage-ui/src/stores/character/orchestrator/store.ts b/packages/stage-ui/src/stores/character/orchestrator/store.ts index d87109e71..5fe86eff6 100644 --- a/packages/stage-ui/src/stores/character/orchestrator/store.ts +++ b/packages/stage-ui/src/stores/character/orchestrator/store.ts @@ -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'], }) } diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts index 13171c8e8..e74a0b17e 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts @@ -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 - -export interface CreateSparkNotifyToolsOptions { - onCommands: (commands: SparkNotifyCommandDraft[]) => void - onNoResponse: () => void -} - -function normalizeSparkNotifyCommand( - command: z.infer['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) || 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 - 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' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55baf81a9..18834e48e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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:'