From 438dd9d5a1448edd653eb9d7ee351bd47440c36d Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 2 Apr 2026 20:08:25 +0800 Subject: [PATCH] fix(stage-ui): spark command tool --- .../event-handler-spark-notify/index.ts | 106 +------- .../character/orchestrator/index.test.ts | 8 +- .../stores/character/orchestrator/store.ts | 2 +- packages/stage-ui/src/stores/llm.ts | 85 +----- .../stage-ui/src/tools/character/index.ts | 1 + .../src/tools/character/orchestrator/index.ts | 3 + .../orchestrator/spark-command-shared.ts | 221 +++++++++++++++ .../orchestrator/spark-command.test.ts | 257 ++++++++++++++++++ .../character/orchestrator/spark-command.ts | 70 +++++ .../orchestrator/spark-notify.test.ts | 96 +++++++ .../character/orchestrator/spark-notify.ts | 108 ++++++++ packages/stage-ui/src/tools/debug.ts | 2 +- packages/stage-ui/src/tools/index.ts | 1 + packages/stage-ui/src/tools/mcp.test.ts | 6 +- packages/stage-ui/src/tools/mcp.ts | 10 +- 15 files changed, 781 insertions(+), 195 deletions(-) create mode 100644 packages/stage-ui/src/tools/character/index.ts create mode 100644 packages/stage-ui/src/tools/character/orchestrator/index.ts create mode 100644 packages/stage-ui/src/tools/character/orchestrator/spark-command-shared.ts create mode 100644 packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts create mode 100644 packages/stage-ui/src/tools/character/orchestrator/spark-command.ts create mode 100644 packages/stage-ui/src/tools/character/orchestrator/spark-notify.test.ts create mode 100644 packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts diff --git a/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts b/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts index 44e1515d7..24bcf90ea 100644 --- a/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts +++ b/packages/stage-ui/src/stores/character/orchestrator/agents/event-handler-spark-notify/index.ts @@ -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 - 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) || 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 - - 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) => { diff --git a/packages/stage-ui/src/stores/character/orchestrator/index.test.ts b/packages/stage-ui/src/stores/character/orchestrator/index.test.ts index d1503eaf5..9415f1d31 100644 --- a/packages/stage-ui/src/stores/character/orchestrator/index.test.ts +++ b/packages/stage-ui/src/stores/character/orchestrator/index.test.ts @@ -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) { return candidates.find((candidate: Record) => 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) + }] } satisfies z.infer) } await options?.onStreamEvent?.({ type: 'text-delta', text: 'Ahhh, got hit by zombie!' } satisfies StreamEvent) diff --git a/packages/stage-ui/src/stores/character/orchestrator/store.ts b/packages/stage-ui/src/stores/character/orchestrator/store.ts index 56ebd496f..a2116d228 100644 --- a/packages/stage-ui/src/stores/character/orchestrator/store.ts +++ b/packages/stage-ui/src/stores/character/orchestrator/store.ts @@ -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() diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index e3e2af43a..9320665f5 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -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 diff --git a/packages/stage-ui/src/tools/character/index.ts b/packages/stage-ui/src/tools/character/index.ts new file mode 100644 index 000000000..0ca31b470 --- /dev/null +++ b/packages/stage-ui/src/tools/character/index.ts @@ -0,0 +1 @@ +export * from './orchestrator' diff --git a/packages/stage-ui/src/tools/character/orchestrator/index.ts b/packages/stage-ui/src/tools/character/orchestrator/index.ts new file mode 100644 index 000000000..6a717acfd --- /dev/null +++ b/packages/stage-ui/src/tools/character/orchestrator/index.ts @@ -0,0 +1,3 @@ +export * from './spark-command' +export * from './spark-command-shared' +export * from './spark-notify' diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command-shared.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command-shared.ts new file mode 100644 index 000000000..c1b411fc2 --- /dev/null +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command-shared.ts @@ -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[] | undefined, +): Record | 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>((acc, entry) => { + acc[entry.key] = entry.value + return acc + }, {}) +} + +export function normalizeSparkCommandPersona( + persona: z.infer[] | undefined, +): Record | 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>((acc, entry) => { + acc[entry.traits] = entry.strength + return acc + }, {}) +} + +export function normalizeSparkCommandGuidanceOptions( + options: z.infer[], +) { + // 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['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 => 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 +} diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts new file mode 100644 index 000000000..80a90ed26 --- /dev/null +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts @@ -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) + }) +}) diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts new file mode 100644 index 000000000..6d2662d60 --- /dev/null +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts @@ -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 + 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(', ')}` + }, + }) +} diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.test.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-notify.test.ts new file mode 100644 index 000000000..6a31fb831 --- /dev/null +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-notify.test.ts @@ -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) + }) +}) diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts new file mode 100644 index 000000000..13171c8e8 --- /dev/null +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts @@ -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 + +export interface CreateSparkNotifyToolsOptions { + onCommands: (commands: SparkNotifyCommandDraft[]) => void + onNoResponse: () => void +} + +function normalizeSparkNotifyCommand( + command: z.infer['commands'][number], +): SparkNotifyCommandDraft { + // NOTICE: The notify-agent tool schema preserves the LLM-facing payload shape, but the + // orchestrator stores runtime drafts in the websocket event shape. This normalizes array + // persona entries and nullable guidance fields back into the draft shape expected downstream. + return { + destinations: command.destinations, + guidance: command.guidance + ? { + type: command.guidance.type, + persona: command.guidance.persona?.reduce((acc, curr) => { + acc[curr.traits] = curr.strength + return acc + }, {} as Record) || undefined, + options: command.guidance.options.map(option => ({ + ...option, + rationale: option.rationale ?? undefined, + possibleOutcome: option.possibleOutcome?.length ? option.possibleOutcome : undefined, + risk: option.risk ?? undefined, + fallback: option.fallback?.length ? option.fallback : undefined, + triggers: option.triggers?.length ? option.triggers : undefined, + })), + } + : undefined, + // TODO: contexts can be added later + contexts: [], + priority: command.priority || 'normal', + intent: command.intent || 'action', + ack: command.ack || undefined, + interrupt: command.interrupt === 'false' || command.interrupt == null ? false : command.interrupt, + } +} + +export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOptions) { + const sparkNoResponseTool = rawTool({ + name: 'builtIn_sparkNoResponse', + description: 'Indicate that no response or action is needed for the current spark:notify event.', + // NOTICE: Keep the same raw-tool + normalized-schema path as the general spark command + // tool so built-in notify tools do not regress on the same provider-specific schema checks. + parameters: normalizeNullableAnyOf(await toJsonSchema(z.object({}).strict()) as any), + execute: async () => { + options.onNoResponse() + return 'AIRI System: Acknowledged, no response or action will be processed.' + }, + }) + + const sparkCommandTool = rawTool({ + name: 'builtIn_sparkCommand', + description: 'Issue a spark:command to sub-agents. You can call this tool multiple times to issue matrices of commands to different sub-agents as needed.', + // NOTICE: `sparkNotifyCommandSchema` keeps the notify-agent input shape, but its emitted + // JSON Schema still passes through the shared nullable-union normalizer before rawTool + // freezes it for OpenAI-compatible providers. + parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any), + execute: async (rawPayload) => { + try { + const payload = rawPayload as z.infer + const validated = await validate(sparkNotifyCommandSchema, payload) + options.onCommands(validated.commands.map(normalizeSparkNotifyCommand)) + } + catch (error) { + return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}` + } + + return 'AIRI System: Acknowledged, command fired.' + }, + }) + + return { + tools: [ + sparkNoResponseTool, + sparkCommandTool, + ], + } +} diff --git a/packages/stage-ui/src/tools/debug.ts b/packages/stage-ui/src/tools/debug.ts index 7286dbe61..e3a77606c 100644 --- a/packages/stage-ui/src/tools/debug.ts +++ b/packages/stage-ui/src/tools/debug.ts @@ -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) => { diff --git a/packages/stage-ui/src/tools/index.ts b/packages/stage-ui/src/tools/index.ts index d1fc2cfba..ed524d45a 100644 --- a/packages/stage-ui/src/tools/index.ts +++ b/packages/stage-ui/src/tools/index.ts @@ -1,2 +1,3 @@ +export * from './character' export * from './debug' export * from './mcp' diff --git a/packages/stage-ui/src/tools/mcp.test.ts b/packages/stage-ui/src/tools/mcp.test.ts index 26b3ce694..87f8b1f2d 100644 --- a/packages/stage-ui/src/tools/mcp.test.ts +++ b/packages/stage-ui/src/tools/mcp.test.ts @@ -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! diff --git a/packages/stage-ui/src/tools/mcp.ts b/packages/stage-ui/src/tools/mcp.ts index 1d174d56d..60bdf5b0e 100644 --- a/packages/stage-ui/src/tools/mcp.ts +++ b/packages/stage-ui/src/tools/mcp.ts @@ -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) : {}