fix(stage-ui): spark command tool
This commit is contained in:
+8
-98
@@ -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,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,
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from './character'
|
||||
export * from './debug'
|
||||
export * from './mcp'
|
||||
|
||||
@@ -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,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) : {}
|
||||
|
||||
Reference in New Issue
Block a user