fix(stage-ui): spark command tool

This commit is contained in:
Neko Ayaka
2026-04-02 20:08:40 +08:00
parent ab5b211ec4
commit 438dd9d5a1
15 changed files with 781 additions and 195 deletions
@@ -2,25 +2,16 @@ 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 { errorMessageFrom } from '@moeru/std'
import { tool } from '@xsai/tool'
import { nanoid } from 'nanoid'
import { validate } from 'xsschema'
import { z } from 'zod'
import { createSparkNotifyTools } from '../../../../../tools'
import { getEventSourceKey } from '../../../../../utils'
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 type { SparkNotifyCommandDraft, SparkNotifyCommandSchema } from '../../../../../tools'
export { sparkNotifyCommandSchema } from '../../../../../tools'
export interface SparkNotifyResponse {
reaction?: string
@@ -71,35 +62,6 @@ function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
].join('\n')
}
export const sparkCommandSchema = z.object({
commands: z.array(z.object({
destinations: z.array(z.string()).min(1).describe('List of sub-agent IDs to send the command to'),
interrupt: z.enum(['force', 'soft', 'false']).nullable().describe('Interrupt type: force, soft, or false (no interrupt). A option to control whether this command is urgent enough to preempt ongoing tasks and require immediate attention.'),
priority: z.enum(['critical', 'high', 'normal', 'low']).nullable().describe('Semantic priority of the command, this affects how sub-agents prioritize it (queues, interruption queues, mq, etc.).'),
intent: z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']).nullable().describe('Intent of the command, indicating the nature of the instruction. If you attend to call other tools, use "plan" to reply with quick response to corresponding module / sub-agent.'),
ack: z.string().describe('Acknowledgment content used to be passed to sub-agents upon command receipt.'),
guidance: z.object({
type: z.enum(['proposal', 'instruction', 'memory-recall']),
persona: z.array(z.object({
strength: z.enum(['very-high', 'high', 'medium', 'low', 'very-low']),
traits: z.string().describe('Trait name to adjust behavior. For example, "bravery", "cautiousness", "friendliness".'),
}).strict()).nullable().describe('Personas can be used to adjust the behavior of sub-agents. For example, when using as NPC in games, or player in Minecraft, the persona can help define the character\'s traits and decision-making style.'),
options: z.array(z.object({
label: z.string().describe('Short and brief label for this option, used for identification, should be within a sentence.'),
steps: z.array(z.string()).describe('Step-by-step instructions for the sub-agent to follow, useful when providing detailed guidance.'),
rationale: z.string().nullable().describe('How this option is derived or proposed, why it makes sense.'),
possibleOutcome: z.array(z.string()).nullable().describe('Simulate possible outcomes of following this option.'),
risk: z.enum(['high', 'medium', 'low', 'none']).nullable(),
fallback: z.array(z.string()).nullable().describe('Fallback steps if the main steps cannot be completed.'),
// TODO: consider to remove or enrich how triggers should work later
triggers: z.array(z.string()).nullable().describe('Conditions or events that would trigger this option.'),
}).strict()),
}).strict().nullable().describe('Guidance for the sub-agent on how to interpret and execute the command with given context, persona settings, and reasoning.'),
}).strict()).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 SparkCommandSchema = z.infer<typeof sparkCommandSchema>
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
const activeProvider = deps.getActiveProvider()
@@ -114,60 +76,11 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
let noResponse = false
const sparkNoResponseTool = await tool({
name: 'builtIn_sparkNoResponse',
description: 'Indicate that no response or action is needed for the current spark:notify event.',
parameters: z.object({}).strict(),
execute: async () => {
const { tools } = await createSparkNotifyTools({
onNoResponse: () => {
noResponse = true
return 'AIRI System: Acknowledged, no response or action will be processed.'
},
})
const sparkCommandTool = await tool({
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.',
parameters: sparkCommandSchema,
execute: async (payload) => {
try {
const validated = await validate(sparkCommandSchema, payload)
commandDrafts.push(...validated.commands.map((cmd) => {
const parsedCmd = {
destinations: cmd.destinations,
guidance: cmd.guidance
? {
type: cmd.guidance.type,
persona: cmd.guidance?.persona?.reduce((acc, curr) => {
acc[curr.traits] = curr.strength
return acc
}, {} as Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>) || undefined,
options: cmd.guidance.options.map(opt => ({
...opt,
rationale: opt.rationale ?? undefined,
possibleOutcome: opt.possibleOutcome?.length ? opt.possibleOutcome : undefined,
risk: opt.risk ?? undefined,
fallback: opt.fallback?.length ? opt.fallback : undefined,
triggers: opt.triggers?.length ? opt.triggers : undefined,
})),
}
: undefined,
// TODO: contexts can be added later
contexts: [],
priority: cmd.priority || 'normal',
intent: cmd.intent || 'action',
ack: cmd.ack || undefined,
interrupt: cmd.interrupt === 'false' || cmd.interrupt == null ? false : cmd.interrupt,
} satisfies Omit<WebSocketEvents['spark:command'], 'id' | 'eventId' | 'parentEventId' | 'commandId'>
return parsedCmd
}))
}
catch (error) {
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
}
return 'AIRI System: Acknowledged, command fired.'
},
onCommands: commands => commandDrafts.push(...commands),
})
const systemMessage: Message = {
@@ -189,10 +102,7 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
let fullText = ''
await deps.stream(activeModel, chatProvider, [systemMessage, userMessage], {
tools: [
sparkNoResponseTool,
sparkCommandTool,
],
tools,
supportsTools: true,
waitForTools: true,
onStreamEvent: async (streamEvent: StreamEvent) => {
@@ -16,7 +16,7 @@ import { nanoid } from 'nanoid'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sparkCommandSchema, useCharacterOrchestratorStore } from '.'
import { sparkNotifyCommandSchema, useCharacterOrchestratorStore } from '.'
import { useCharacterStore } from '..'
import { useLLM } from '../../llm'
import { useAiriCardStore, useConsciousnessStore } from '../../modules'
@@ -75,12 +75,12 @@ function getArraySchema(schema?: Record<string, any>) {
return candidates.find((candidate: Record<string, any>) => candidate?.type === 'array')
}
describe('sparkCommandSchema', () => {
describe('sparkNotifyCommandSchema', () => {
it('emits strict objects in the json schema', async () => {
const sparkTool = await tool({
name: 'builtIn_sparkCommand',
description: 'test',
parameters: sparkCommandSchema,
parameters: sparkNotifyCommandSchema,
execute: async () => undefined,
})
@@ -152,7 +152,7 @@ describe('store character-orchestrator', () => {
interrupt: 'false',
ack: 'ok',
guidance: null,
}] } satisfies z.infer<typeof sparkCommandSchema>)
}] } satisfies z.infer<typeof sparkNotifyCommandSchema>)
}
await options?.onStreamEvent?.({ type: 'text-delta', text: 'Ahhh, got hit by zombie!' } satisfies StreamEvent)
@@ -10,7 +10,7 @@ import { useConsciousnessStore } from '../../modules/consciousness'
import { useProvidersStore } from '../../providers'
import { setupAgentSparkNotifyHandler } from './agents/event-handler-spark-notify'
export { sparkCommandSchema } from './agents/event-handler-spark-notify'
export { sparkNotifyCommandSchema } from './agents/event-handler-spark-notify'
export const useCharacterOrchestratorStore = defineStore('character-orchestrator', () => {
const { stream } = useLLM()
+2 -83
View File
@@ -2,16 +2,12 @@ import type { WebSocketEvents } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { CommonContentPart, CompletionToolCall, Message, Tool } from '@xsai/shared-chat'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { listModels } from '@xsai/model'
import { streamText } from '@xsai/stream-text'
import { tool } from '@xsai/tool'
import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { z } from 'zod/v4'
import { debug, mcp } from '../tools'
import { createSparkCommandTool, debug, mcp } from '../tools'
import { useModsServerChannelStore } from './mods/api/channel-server'
export type StreamEvent
@@ -58,50 +54,6 @@ function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProv
return options?.toolsCompatibility?.get(key) !== false
}
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.string().optional().describe('Why this option makes sense.'),
possibleOutcome: z.array(z.string()).optional().describe('Expected outcomes if this option is followed.'),
risk: z.enum(['high', 'medium', 'low', 'none']).optional().describe('Risk level of this option.'),
fallback: z.array(z.string()).optional().describe('Fallback steps if the main plan fails.'),
triggers: z.array(z.string()).optional().describe('Conditions that should trigger this option.'),
}).strict()
const sparkCommandContextSchema = z.object({
lane: z.string().optional().describe('Logical context lane, for example "game" or "memory".'),
ideas: z.array(z.string()).optional().describe('Loose ideas to attach to the target context.'),
hints: z.array(z.string()).optional().describe('Hints to attach to the target context.'),
strategy: z.nativeEnum(ContextUpdateStrategy).describe('How the target should merge this context update.'),
text: z.string().describe('Primary text of the context update.'),
destinations: z.union([
z.array(z.string()),
z.object({
all: z.literal(true),
}).strict(),
z.object({
include: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
}).strict(),
]).optional().describe('Optional routing for the attached context update.'),
metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional().describe('JSON-like metadata for the context update.'),
}).strict()
const sparkCommandToolSchema = z.object({
destinations: z.array(z.string()).min(1).describe('One or more target module or agent IDs for this command.'),
interrupt: z.union([z.literal('force'), z.literal('soft'), z.literal(false)]).optional().describe('Whether the command should preempt current work.'),
priority: z.enum(['critical', 'high', 'normal', 'low']).optional().describe('Priority of the command.'),
intent: z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']).optional().describe('Intent of the command.'),
ack: z.string().optional().describe('Short acknowledgement or instruction summary for the receiver.'),
parentEventId: z.string().optional().describe('Optional parent event ID when this command is a response to another event.'),
guidance: z.object({
type: z.enum(['proposal', 'instruction', 'memory-recall']),
persona: z.record(z.string(), z.enum(['very-high', 'high', 'medium', 'low', 'very-low'])).optional().describe('Persona traits that shape the target behavior.'),
options: z.array(sparkCommandGuidanceOptionSchema).min(1).describe('Concrete execution options for the target.'),
}).strict().optional().describe('Structured guidance for how the target should interpret and execute the command.'),
contexts: z.array(sparkCommandContextSchema).optional().describe('Optional context updates to attach to the command.'),
}).strict()
async function streamFrom(model: string, chatProvider: ChatProvider, messages: Message[], sendSparkCommand: (command: WebSocketEvents['spark:command']) => void, options?: StreamOptions) {
const chatConfig = chatProvider.chat(model)
const sanitized = sanitizeMessages(messages as unknown[])
@@ -119,40 +71,7 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M
...await mcp(),
...await debug(),
...await resolveTools(),
await tool({
name: 'call_spark_command',
description: 'Send a spark:command to one or more frontend-connected modules or sub-agents.',
parameters: sparkCommandToolSchema,
execute: async (payload) => {
const command = {
id: nanoid(),
eventId: nanoid(),
parentEventId: payload.parentEventId,
commandId: nanoid(),
interrupt: payload.interrupt ?? false,
priority: payload.priority ?? 'normal',
intent: payload.intent ?? 'action',
ack: payload.ack,
guidance: payload.guidance,
contexts: payload.contexts?.map(context => ({
id: nanoid(),
contextId: nanoid(),
lane: context.lane,
ideas: context.ideas,
hints: context.hints,
strategy: context.strategy,
text: context.text,
destinations: context.destinations,
metadata: context.metadata,
})),
destinations: payload.destinations,
} satisfies WebSocketEvents['spark:command']
sendSparkCommand(command)
return `spark:command sent (${command.commandId}) to ${command.destinations.join(', ')}`
},
}),
await createSparkCommandTool({ sendSparkCommand }),
]
: undefined
@@ -0,0 +1 @@
export * from './orchestrator'
@@ -0,0 +1,3 @@
export * from './spark-command'
export * from './spark-command-shared'
export * from './spark-notify'
@@ -0,0 +1,221 @@
import type { JsonSchema } from 'xsschema'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { z } from 'zod/v4'
const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
export const sparkCommandIntentSchema = z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context'])
export const sparkCommandPrioritySchema = z.enum(['critical', 'high', 'normal', 'low'])
export const sparkCommandInterruptSchema = z.union([z.literal('force'), z.literal('soft'), z.literal(false)])
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('Personas can be used to adjust the behavior of sub-agents. For example, when using as NPC in games, or player in Minecraft, the persona can help define the character\'s traits and decision-making style.'),
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). A option to control whether this command is urgent enough to preempt ongoing tasks and require immediate attention.'),
priority: z.union([z.enum(['critical', 'high', 'normal', 'low']), z.null()]).describe('Semantic priority of the command, this affects how sub-agents prioritize it (queues, interruption queues, mq, etc.).'),
intent: z.union([z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']), z.null()]).describe('Intent of the command, indicating the nature of the instruction. If you attend to call other tools, use "plan" to reply with quick response to corresponding module / sub-agent.'),
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 with given context, persona settings, and reasoning.'),
}).strict()
export const sparkCommandMetadataEntrySchema = z.object({
key: z.string().describe('Metadata key.'),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]).describe('Metadata value.'),
}).strict()
export const sparkCommandContextSchema = z.object({
lane: z.union([z.string(), z.null()]).describe('Logical context lane, for example "game" or "memory".'),
ideas: z.union([z.array(z.string()), z.null()]).describe('Loose ideas to attach to the target context.'),
hints: z.union([z.array(z.string()), z.null()]).describe('Hints to attach to the target context.'),
strategy: z.enum(ContextUpdateStrategy).describe('How the target should merge this context update.'),
text: z.string().describe('Primary text of the context update.'),
destinations: z.union([
z.array(z.string()),
z.object({
all: z.literal(true),
}).strict(),
z.object({
include: z.union([z.array(z.string()), z.null()]).describe('Included destinations.'),
exclude: z.union([z.array(z.string()), z.null()]).describe('Excluded destinations.'),
}).strict(),
]).nullable().describe('Optional routing for the attached context update.'),
metadata: z.union([z.array(sparkCommandMetadataEntrySchema), z.null()]).describe('JSON-like metadata for the context update, expressed as key-value pairs for schema compatibility.'),
}).strict()
export const sparkCommandGuidanceSchema = z.object({
type: z.enum(['proposal', 'instruction', 'memory-recall']),
persona: z.union([z.array(sparkCommandPersonaSchema), z.null()]).describe('Persona traits that shape the target behavior.'),
options: z.array(sparkCommandGuidanceOptionSchema).min(1).describe('Concrete execution options for the target.'),
}).strict()
export const sparkCommandToolSchema = z.object({
destinations: z.array(z.string()).min(1).describe('One or more target module or agent IDs for this command.'),
// NOTICE: Azure/OpenAI-compatible tool validators reject strict object schemas when some
// properties are optional. These root fields stay required in the provider-facing schema
// and use `null` as the "not supplied" value, then runtime code normalizes them back to
// `undefined` or defaults before emitting `spark:command`.
interrupt: z.union([sparkCommandInterruptSchema, z.null()]).describe('Whether the command should preempt current work.'),
priority: z.union([sparkCommandPrioritySchema, z.null()]).describe('Priority of the command.'),
intent: z.union([sparkCommandIntentSchema, z.null()]).describe('Intent of the command.'),
ack: z.union([z.string(), z.null()]).describe('Short acknowledgement or instruction summary for the receiver.'),
parentEventId: z.union([z.string(), z.null()]).describe('Optional parent event ID when this command is a response to another event.'),
guidance: z.union([sparkCommandGuidanceSchema, z.null()]).describe('Structured guidance for how the target should interpret and execute the command.'),
contexts: z.union([z.array(sparkCommandContextSchema), z.null()]).describe('Optional context updates to attach to the command.'),
}).strict()
export function normalizeSparkCommandMetadata(
metadata: z.infer<typeof sparkCommandMetadataEntrySchema>[] | undefined,
): Record<string, string | number | boolean | null> | undefined {
// NOTICE: Provider-facing schemas model metadata as `[{ key, value }]` because
// `z.record(...)` emits `propertyNames`, which OpenAI-compatible validators may reject.
// Runtime `spark:command` events still expect a plain object map, so we rebuild that here.
if (!metadata?.length)
return undefined
return metadata.reduce<Record<string, string | number | boolean | null>>((acc, entry) => {
acc[entry.key] = entry.value
return acc
}, {})
}
export function normalizeSparkCommandPersona(
persona: z.infer<typeof sparkCommandPersonaSchema>[] | undefined,
): Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'> | undefined {
// NOTICE: Persona traits are exposed to providers as an array of `{ traits, strength }`
// entries for schema compatibility. The channel-server event shape uses a record keyed by
// trait name instead, so this collapses the provider-safe array back into that runtime map.
if (!persona?.length)
return undefined
return persona.reduce<Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>>((acc, entry) => {
acc[entry.traits] = entry.strength
return acc
}, {})
}
export function normalizeSparkCommandGuidanceOptions(
options: z.infer<typeof sparkCommandGuidanceOptionSchema>[],
) {
// NOTICE: Provider-facing schemas keep nullable fields required so strict-object validation
// passes on Azure/OpenAI-compatible providers. Runtime guidance objects use omitted fields
// instead of `null`, so this strips empty/null values back to the original event shape.
return 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,
}))
}
export function normalizeSparkCommandDestinations(
destinations: z.infer<typeof sparkCommandContextSchema>['destinations'],
) {
// NOTICE: The provider schema keeps destination filters nullable and fully required inside
// the strict object branch. Runtime context updates only want meaningful routing filters, so
// this removes null/empty filter values and returns `undefined` when no routing remains.
if (destinations == null)
return undefined
if (Array.isArray(destinations) || 'all' in destinations)
return destinations
const include = destinations.include?.length ? destinations.include : undefined
const exclude = destinations.exclude?.length ? destinations.exclude : undefined
if (!include && !exclude)
return undefined
return {
include,
exclude,
}
}
export function normalizeSparkCommandStringList(value: string[] | null): string[] | undefined {
// NOTICE: Several provider-facing fields are required-but-nullable to satisfy strict object
// validation. Runtime context updates treat missing lists as omitted, not `null` or `[]`.
return value?.length ? value : undefined
}
export function normalizeSparkCommandStringValue(value: string | null): string | undefined {
// NOTICE: Required-but-nullable provider fields are normalized back to the runtime
// convention of omitting absent scalar values with `undefined`.
return value ?? undefined
}
function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema {
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
}
export function normalizeNullableAnyOf(schema: JsonSchema): JsonSchema {
// NOTICE: `xsschema` emits nullable unions like `string | null` as `anyOf`, but some
// OpenAI-compatible validators reject those forms while accepting `type: ['string', 'null']`.
// We only collapse scalar-or-null unions here; object unions must remain untouched so their
// nested `required` and `additionalProperties` constraints survive provider validation.
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 => type !== undefined && JSON_SCHEMA_NULLABLE_SCALAR_TYPES.has(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
}
@@ -0,0 +1,257 @@
import type { JsonSchema } from 'xsschema'
import z from 'zod/v4'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { rawTool } from '@xsai/tool'
import { describe, expect, it, vi } from 'vitest'
import { toJsonSchema } from 'xsschema'
import { createSparkCommandTool } from './spark-command'
import { normalizeNullableAnyOf, sparkNotifyCommandItemSchema } from './spark-command-shared'
function isJsonSchema(value: JsonSchema | boolean | undefined): value is JsonSchema {
return Boolean(value && typeof value === 'object')
}
function getObjectSchema(schema?: JsonSchema) {
if (!schema)
return undefined
if (schema.type === 'object')
return schema
const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])].filter(isJsonSchema)
return candidates.find(candidate => candidate?.type === 'object')
}
function getArraySchema(schema?: JsonSchema) {
if (!schema)
return undefined
if (schema.type === 'array')
return schema
const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])].filter(isJsonSchema)
return candidates.find(candidate => candidate?.type === 'array')
}
function findObjectSchema(schema: JsonSchema | undefined, predicate: (schema: JsonSchema) => boolean): JsonSchema | undefined {
if (!schema)
return undefined
const objectSchema = getObjectSchema(schema)
if (objectSchema && predicate(objectSchema))
return objectSchema
for (const candidate of [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])].filter(isJsonSchema)) {
const found = findObjectSchema(candidate, predicate)
if (found)
return found
}
return undefined
}
describe('tools/character/orchestrator/spark-command', () => {
it('normalizes scalar|null anyOf into a type array', async () => {
const schemaTestUnion = await toJsonSchema(z.object({
testField: z.union([z.string(), z.null()]),
}))
const normalized = normalizeNullableAnyOf(schemaTestUnion as JsonSchema)
expect((normalized.properties?.testField as JsonSchema).type).toEqual(['string', 'null'])
expect((normalized.properties?.testField as JsonSchema).anyOf).toBeUndefined()
})
it('deduplicates primitive types after normalization', async () => {
const schemaTestUnion = await toJsonSchema(z.object({
testField: z.union([z.literal('force'), z.literal('soft'), z.literal(false)]),
}))
const normalized = normalizeNullableAnyOf(schemaTestUnion as JsonSchema)
expect((normalized.properties?.testField as JsonSchema).type).toEqual(['string', 'boolean'])
expect((normalized.properties?.testField as JsonSchema).anyOf).toBeUndefined()
})
it('should render sparkNotifyCommandItemSchema into correct schema', async () => {
const schemaTest = await toJsonSchema(sparkNotifyCommandItemSchema)
const normalized = normalizeNullableAnyOf(schemaTest as JsonSchema)
const res = rawTool({
name: 'test_tool',
strict: true,
parameters: normalized,
execute: () => ({ success: true }),
})
expect(res.function.parameters).toStrictEqual(normalized)
})
it('emits a strict parameter schema', async () => {
const tool = await createSparkCommandTool({
sendSparkCommand: () => undefined,
})
expect(tool.function.name).toBe('builtIn_emitSparkCommand')
expect(tool.function.parameters.additionalProperties).toBe(false)
})
it('avoids propertyNames in provider-facing schema', async () => {
const tool = await createSparkCommandTool({
sendSparkCommand: () => undefined,
})
const schema = tool.function.parameters as JsonSchema
const guidance = getObjectSchema(schema.properties?.guidance as JsonSchema)
const guidancePersona = guidance?.properties?.persona as JsonSchema
const contexts = getArraySchema(schema.properties?.contexts as JsonSchema)
const contextItem = contexts?.items as JsonSchema
const metadata = contextItem.properties?.metadata as JsonSchema
expect(guidancePersona.propertyNames).toBeUndefined()
expect(metadata.propertyNames).toBeUndefined()
})
it('uses explicit required keys for nested strict option objects', async () => {
const tool = await createSparkCommandTool({
sendSparkCommand: () => undefined,
})
const schema = tool.function.parameters as JsonSchema
expect(schema.required).toEqual([
'destinations',
'interrupt',
'priority',
'intent',
'ack',
'parentEventId',
'guidance',
'contexts',
])
const guidance = getObjectSchema(schema.properties?.guidance as JsonSchema)
const options = guidance?.properties?.options as JsonSchema
const optionItem = options.items as JsonSchema
const contexts = getArraySchema(schema.properties?.contexts as JsonSchema)
const contextItem = contexts?.items as JsonSchema
const destinations = contextItem.properties?.destinations as JsonSchema
const destinationsFilter = findObjectSchema(
destinations,
candidate => Boolean(candidate.properties?.include || candidate.properties?.exclude),
)
expect(guidance?.required).toEqual([
'type',
'persona',
'options',
])
expect(optionItem.required).toEqual([
'label',
'steps',
'rationale',
'possibleOutcome',
'risk',
'fallback',
'triggers',
])
expect(contextItem.required).toEqual([
'lane',
'ideas',
'hints',
'strategy',
'text',
'destinations',
'metadata',
])
expect(destinationsFilter?.required).toEqual([
'include',
'exclude',
])
})
it('builds and dispatches spark commands with generated ids', async () => {
const sendSparkCommand = vi.fn()
const tool = await createSparkCommandTool({
sendSparkCommand,
})
const result = await tool.execute({
destinations: ['minecraft'],
interrupt: 'soft',
priority: 'high',
intent: 'proposal',
ack: 'check this',
parentEventId: 'parent-1',
guidance: {
type: 'instruction',
persona: [
{ traits: 'bravery', strength: 'high' },
],
options: [{
label: 'Move',
steps: ['Walk forward'],
rationale: 'Closer inspection',
possibleOutcome: null,
risk: null,
fallback: null,
triggers: null,
}],
},
contexts: [{
lane: 'game',
ideas: null,
hints: null,
strategy: ContextUpdateStrategy.AppendSelf,
text: 'Zombie nearby',
destinations: ['memory'],
metadata: [
{ key: 'threat', value: 'zombie' },
{ key: 'urgent', value: true },
],
}],
}, { messages: [], toolCallId: 'tool-call-id' })
expect(sendSparkCommand).toHaveBeenCalledTimes(1)
expect(sendSparkCommand).toHaveBeenCalledWith(expect.objectContaining({
parentEventId: 'parent-1',
interrupt: 'soft',
priority: 'high',
intent: 'proposal',
ack: 'check this',
destinations: ['minecraft'],
guidance: {
type: 'instruction',
persona: {
bravery: 'high',
},
options: [{
label: 'Move',
steps: ['Walk forward'],
rationale: 'Closer inspection',
possibleOutcome: undefined,
risk: undefined,
fallback: undefined,
triggers: undefined,
}],
},
contexts: [expect.objectContaining({
lane: 'game',
strategy: ContextUpdateStrategy.AppendSelf,
text: 'Zombie nearby',
destinations: ['memory'],
metadata: {
threat: 'zombie',
urgent: true,
},
})],
}))
const command = sendSparkCommand.mock.calls[0][0]
expect(command.id).toEqual(expect.any(String))
expect(command.eventId).toEqual(expect.any(String))
expect(command.commandId).toEqual(expect.any(String))
expect(command.contexts?.[0].id).toEqual(expect.any(String))
expect(command.contexts?.[0].contextId).toEqual(expect.any(String))
expect(result).toContain('spark:command sent')
expect(result).toContain(command.commandId)
})
})
@@ -0,0 +1,70 @@
import type { WebSocketEvents } from '@proj-airi/server-sdk'
import type z from 'zod/v4'
import { rawTool } from '@xsai/tool'
import { nanoid } from 'nanoid'
import { toJsonSchema } from 'xsschema'
import {
normalizeNullableAnyOf,
normalizeSparkCommandDestinations,
normalizeSparkCommandGuidanceOptions,
normalizeSparkCommandMetadata,
normalizeSparkCommandPersona,
normalizeSparkCommandStringList,
normalizeSparkCommandStringValue,
sparkCommandToolSchema,
} from './spark-command-shared'
export interface CreateSparkCommandToolOptions {
sendSparkCommand: (command: WebSocketEvents['spark:command']) => void
}
export async function createSparkCommandTool(options: CreateSparkCommandToolOptions) {
// NOTICE: We intentionally bypass `tool(...)` here so we can normalize the generated
// JSON Schema before `strictJsonSchema(...)` finalizes it. This is required for providers
// like Azure that reject some `anyOf` nullable forms and strict-object optional-field shapes.
const parameters = normalizeNullableAnyOf(await toJsonSchema(sparkCommandToolSchema) as any)
return rawTool({
name: 'builtIn_emitSparkCommand',
description: 'Send a spark:command to one or more frontend-connected modules or sub-agents.',
parameters,
execute: async (rawPayload) => {
const payload = rawPayload as z.infer<typeof sparkCommandToolSchema>
const command = {
id: nanoid(),
eventId: nanoid(),
parentEventId: payload.parentEventId ?? undefined,
commandId: nanoid(),
interrupt: payload.interrupt ?? false,
priority: payload.priority ?? 'normal',
intent: payload.intent ?? 'action',
ack: payload.ack ?? undefined,
guidance: payload.guidance
? {
type: payload.guidance.type,
persona: normalizeSparkCommandPersona(payload.guidance.persona ?? undefined),
options: normalizeSparkCommandGuidanceOptions(payload.guidance.options),
}
: undefined,
contexts: payload.contexts?.map(context => ({
id: nanoid(),
contextId: nanoid(),
lane: normalizeSparkCommandStringValue(context.lane),
ideas: normalizeSparkCommandStringList(context.ideas),
hints: normalizeSparkCommandStringList(context.hints),
strategy: context.strategy,
text: context.text,
destinations: normalizeSparkCommandDestinations(context.destinations),
metadata: normalizeSparkCommandMetadata(context.metadata ?? undefined),
})),
destinations: payload.destinations,
} satisfies WebSocketEvents['spark:command']
options.sendSparkCommand(command)
return `spark:command sent (${command.commandId}) to ${command.destinations.join(', ')}`
},
})
}
@@ -0,0 +1,96 @@
import type { JsonSchema } from 'xsschema'
import { describe, expect, it } from 'vitest'
import { createSparkNotifyTools } from './spark-notify'
describe('tools/character/orchestrator/spark-notify', () => {
it('emits strict parameter objects for spark notify tools', async () => {
const { tools } = await createSparkNotifyTools({
onNoResponse: () => undefined,
onCommands: () => undefined,
})
for (const name of ['builtIn_sparkNoResponse', 'builtIn_sparkCommand']) {
const entry = tools.find(tool => tool.function.name === name)
expect(entry, `missing tool: ${name}`).toBeDefined()
expect(entry?.function.parameters.additionalProperties).toBe(false)
}
})
it('normalizes spark commands before forwarding them', async () => {
const received: unknown[] = []
const { tools } = await createSparkNotifyTools({
onNoResponse: () => undefined,
onCommands: commands => received.push(...commands),
})
const commandTool = tools.find(tool => tool.function.name === 'builtIn_sparkCommand')
expect(commandTool).toBeDefined()
await commandTool!.execute({
commands: [{
destinations: ['minecraft'],
interrupt: 'false',
priority: null,
intent: null,
ack: '',
guidance: {
type: 'proposal',
persona: [
{ traits: 'bravery', strength: 'high' },
{ traits: 'curiosity', strength: 'medium' },
],
options: [{
label: 'Investigate',
steps: ['Walk closer', 'Observe the source'],
rationale: null,
possibleOutcome: [],
risk: null,
fallback: [],
triggers: [],
}],
},
}],
}, { messages: [], toolCallId: 'tool-call-id' })
expect(received).toEqual([{
destinations: ['minecraft'],
interrupt: false,
priority: 'normal',
intent: 'action',
ack: undefined,
contexts: [],
guidance: {
type: 'proposal',
persona: {
bravery: 'high',
curiosity: 'medium',
},
options: [{
label: 'Investigate',
steps: ['Walk closer', 'Observe the source'],
rationale: undefined,
possibleOutcome: undefined,
risk: undefined,
fallback: undefined,
triggers: undefined,
}],
},
}])
})
it('uses an empty strict schema for the no-response tool', async () => {
const { tools } = await createSparkNotifyTools({
onNoResponse: () => undefined,
onCommands: () => undefined,
})
const noResponseTool = tools.find(tool => tool.function.name === 'builtIn_sparkNoResponse')
expect(noResponseTool).toBeDefined()
const schema = noResponseTool!.function.parameters as JsonSchema
expect(schema.type).toBe('object')
expect(schema.properties).toEqual({})
expect(schema.additionalProperties).toBe(false)
})
})
@@ -0,0 +1,108 @@
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,
],
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { z } from 'zod'
const tools = [
tool({
name: 'debug_random_number',
name: 'builtIn_debugRandomNumber',
description: 'Generate a random number between 0 and 1',
execute: async () => {
return new Promise((resolve) => {
+1
View File
@@ -1,2 +1,3 @@
export * from './character'
export * from './debug'
export * from './mcp'
+3 -3
View File
@@ -7,16 +7,16 @@ import { mcp } from './mcp'
describe('tools mcp schema', () => {
it('emits strict parameter objects', async () => {
const tools = await mcp()
for (const name of ['mcp_list_tools', 'mcp_call_tool']) {
for (const name of ['builtIn_mcpListTools', 'builtIn_mcpCallTool']) {
const t = tools.find(entry => entry.function.name === name)
expect(t, `missing tool: ${name}`).toBeDefined()
expect(t?.function.parameters.additionalProperties).toBe(false)
}
})
it('mcp_call_tool uses flat name+arguments schema', async () => {
it('builtIn_mcpCallTool uses flat name+arguments schema', async () => {
const tools = await mcp()
const callTool = tools.find(entry => entry.function.name === 'mcp_call_tool')
const callTool = tools.find(entry => entry.function.name === 'builtIn_mcpCallTool')
expect(callTool).toBeDefined()
const props = (callTool!.function.parameters as JsonSchema).properties!
+5 -5
View File
@@ -5,22 +5,22 @@ import { getMcpToolBridge } from '../stores/mcp-tool-bridge'
const tools = [
tool({
name: 'mcp_list_tools',
description: 'List all available MCP tools. Call this first to discover tool names before calling mcp_call_tool.',
name: 'builtIn_mcpListTools',
description: 'List all available MCP tools. Call this first to discover tool names before calling builtIn_mcpCallTool.',
execute: async () => {
try {
return await getMcpToolBridge().listTools()
}
catch (error) {
console.warn('[mcp_list_tools] failed to list tools:', error)
console.warn('[builtIn_mcpListTools] failed to list tools:', error)
return ''
}
},
parameters: z.object({}).strict(),
}),
tool({
name: 'mcp_call_tool',
description: 'Call an MCP tool by name. Use mcp_list_tools first to get available tool names.',
name: 'builtIn_mcpCallTool',
description: 'Call an MCP tool by name. Use builtIn_mcpListTools first to get available tool names.',
execute: async ({ name, arguments: argsJson }) => {
try {
const args = argsJson ? JSON.parse(argsJson) : {}