diff --git a/packages/core-agent/package.json b/packages/core-agent/package.json index 42c633761..2394c25e3 100644 --- a/packages/core-agent/package.json +++ b/packages/core-agent/package.json @@ -23,6 +23,10 @@ "./agents/spark-notify": { "types": "./dist/agents/spark-notify/index.d.mts", "default": "./dist/agents/spark-notify/index.mjs" + }, + "./agents/spark-command": { + "types": "./dist/agents/spark-command/index.d.mts", + "default": "./dist/agents/spark-command/index.mjs" } }, "main": "./dist/index.mjs", diff --git a/packages/core-agent/src/agents/spark-command/index.ts b/packages/core-agent/src/agents/spark-command/index.ts new file mode 100644 index 000000000..429846b5d --- /dev/null +++ b/packages/core-agent/src/agents/spark-command/index.ts @@ -0,0 +1,17 @@ +export { + normalizeSparkCommandDestinations, + normalizeSparkCommandGuidanceOptions, + normalizeSparkCommandMetadata, + normalizeSparkCommandPersona, + normalizeSparkCommandStringList, + normalizeSparkCommandStringValue, + sparkCommandGuidanceOptionSchema, + sparkCommandGuidanceSchema, + sparkCommandIntentSchema, + sparkCommandInterruptSchema, + sparkCommandPersonaSchema, + sparkCommandPrioritySchema, + sparkCommandToolSchema, +} from './schema' +export type { CreateSparkCommandToolOptions } from './tools' +export { createSparkCommandTool } from './tools' diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command-shared.ts b/packages/core-agent/src/agents/spark-command/schema.ts similarity index 74% rename from packages/stage-ui/src/tools/character/orchestrator/spark-command-shared.ts rename to packages/core-agent/src/agents/spark-command/schema.ts index ff3601a8c..ddc8ebf00 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command-shared.ts +++ b/packages/core-agent/src/agents/spark-command/schema.ts @@ -1,10 +1,21 @@ import { ContextUpdateStrategy } from '@proj-airi/server-sdk' import { z } from 'zod/v4' +/** Allowed intent values for a `spark:command` event. */ export const sparkCommandIntentSchema = z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']) + +/** Allowed priority values for a `spark:command` event. */ export const sparkCommandPrioritySchema = z.enum(['critical', 'high', 'normal', 'low']) + +/** Allowed interrupt values before the parent tool schema adds its nullable provider form. */ export const sparkCommandInterruptSchema = z.union([z.literal('force'), z.literal('soft'), z.literal(false)]) +/** + * Provider-facing schema for one structured guidance option. + * + * Strict providers require every property in this object. Use `null` for an omitted optional + * value. The command tool removes those null and empty values before it emits `spark:command`. + */ 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.'), @@ -15,24 +26,26 @@ export const sparkCommandGuidanceOptionSchema = z.object({ triggers: z.union([z.array(z.string()), z.null()]).describe('Conditions that should trigger this option.'), }).strict() +/** + * Provider-facing persona entry used to avoid dynamic object keys in generated JSON Schema. + * + * The command tool converts these entries into the runtime persona map keyed by `traits`. + */ 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({ +/** + * Provider-facing guidance schema for a Spark Command. + * + * `persona` uses an array of entries instead of a record because some providers reject + * `propertyNames`. The command tool converts it back to the runtime record shape. + */ +export const sparkCommandGuidanceSchema = 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.'), + options: z.array(sparkCommandGuidanceOptionSchema).min(1).describe('Concrete execution options for the target.'), }).strict() export const sparkCommandMetadataEntrySchema = z.object({ @@ -59,12 +72,18 @@ export const sparkCommandContextSchema = z.object({ 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() - +/** + * Provider-facing parameter schema for `builtIn_emitSparkCommand`. + * + * Strict providers require every root property. Use `null` when an optional value is absent. + * The command tool converts null and empty values to runtime omissions or defaults before it + * emits `spark:command`. + * + * This schema does not equal the wire-event shape. Persona and metadata use arrays here so + * generated JSON Schema does not contain dynamic `propertyNames`. The tool converts them back + * to records before delivery. The input requires at least one destination; a transport adapter + * can clear that list when its protocol uses an empty list for broadcast delivery. + */ 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 @@ -80,6 +99,13 @@ export const sparkCommandToolSchema = z.object({ contexts: z.union([z.array(sparkCommandContextSchema), z.null()]).describe('Optional context updates to attach to the command.'), }).strict() +/** + * Converts provider-safe metadata entries into the runtime metadata map. + * + * @example + * normalizeSparkCommandMetadata([{ key: 'urgent', value: true }]) + * // => { urgent: true } + */ export function normalizeSparkCommandMetadata( metadata: z.infer[] | undefined, ): Record | undefined { @@ -95,6 +121,13 @@ export function normalizeSparkCommandMetadata( }, {}) } +/** + * Converts provider-safe persona entries into the runtime persona map. + * + * @example + * normalizeSparkCommandPersona([{ traits: 'bravery', strength: 'high' }]) + * // => { bravery: 'high' } + */ export function normalizeSparkCommandPersona( persona: z.infer[] | undefined, ): Record | undefined { @@ -110,6 +143,13 @@ export function normalizeSparkCommandPersona( }, {}) } +/** + * Removes empty optional fields from guidance options. + * + * @example + * normalizeSparkCommandGuidanceOptions([{ label: 'Wait', steps: ['Wait'], rationale: null, possibleOutcome: [], risk: null, fallback: [], triggers: [] }]) + * // => [{ label: 'Wait', steps: ['Wait'], rationale: undefined, possibleOutcome: undefined, risk: undefined, fallback: undefined, triggers: undefined }] + */ export function normalizeSparkCommandGuidanceOptions( options: z.infer[], ) { @@ -126,6 +166,13 @@ export function normalizeSparkCommandGuidanceOptions( })) } +/** + * Removes empty routing filters from a context update. + * + * @example + * normalizeSparkCommandDestinations({ include: ['memory'], exclude: null }) + * // => { include: ['memory'], exclude: undefined } + */ export function normalizeSparkCommandDestinations( destinations: z.infer['destinations'], ) { @@ -150,12 +197,26 @@ export function normalizeSparkCommandDestinations( } } +/** + * Removes an empty provider-safe string list. + * + * @example + * normalizeSparkCommandStringList([]) + * // => undefined + */ 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 } +/** + * Converts a nullable provider-safe string into the runtime optional value. + * + * @example + * normalizeSparkCommandStringValue(null) + * // => 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`. diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts b/packages/core-agent/src/agents/spark-command/tools.test.ts similarity index 98% rename from packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts rename to packages/core-agent/src/agents/spark-command/tools.test.ts index 171375dd8..6a2f85286 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts +++ b/packages/core-agent/src/agents/spark-command/tools.test.ts @@ -3,7 +3,7 @@ import type { JsonSchema } from 'xsschema' import { ContextUpdateStrategy } from '@proj-airi/server-sdk' import { describe, expect, it, vi } from 'vitest' -import { createSparkCommandTool } from './spark-command' +import { createSparkCommandTool } from './tools' function isJsonSchema(value: JsonSchema | boolean | undefined): value is JsonSchema { return Boolean(value && typeof value === 'object') @@ -48,7 +48,7 @@ function findObjectSchema(schema: JsonSchema | undefined, predicate: (schema: Js return undefined } -describe('tools/character/orchestrator/spark-command', () => { +describe('agents/spark-command/tools', () => { it('emits a strict parameter schema', async () => { const tools = await createSparkCommandTool({ sendSparkCommand: () => undefined, diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts b/packages/core-agent/src/agents/spark-command/tools.ts similarity index 89% rename from packages/stage-ui/src/tools/character/orchestrator/spark-command.ts rename to packages/core-agent/src/agents/spark-command/tools.ts index 849317712..9d3383a52 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts +++ b/packages/core-agent/src/agents/spark-command/tools.ts @@ -13,12 +13,20 @@ import { normalizeSparkCommandStringList, normalizeSparkCommandStringValue, sparkCommandToolSchema, -} from './spark-command-shared' +} from './schema' +/** Options for the Spark Command LLM tool. */ export interface CreateSparkCommandToolOptions { + /** Receives a protocol-ready `spark:command` event. */ sendSparkCommand: (command: WebSocketEvents['spark:command']) => void } +/** + * Creates the LLM tool that emits one `spark:command` event. + * + * The caller owns transport delivery. This function creates provider-facing schemas, + * normalizes the tool payload, and passes the resulting event to `sendSparkCommand`. + */ export async function createSparkCommandTool(options: CreateSparkCommandToolOptions) { // Keep the generated JSON Schema provider-neutral. Each provider adapter // converts unsupported schema forms before it sends the request. diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.test.ts b/packages/core-agent/src/agents/spark-notify/tools.test.ts similarity index 96% rename from packages/stage-ui/src/tools/character/orchestrator/spark-notify.test.ts rename to packages/core-agent/src/agents/spark-notify/tools.test.ts index 978a49912..3b27617a7 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.test.ts +++ b/packages/core-agent/src/agents/spark-notify/tools.test.ts @@ -2,9 +2,9 @@ import type { JsonSchema } from 'xsschema' import { describe, expect, it } from 'vitest' -import { createSparkNotifyTools } from './spark-notify' +import { createSparkNotifyTools } from './tools' -describe('tools/character/orchestrator/spark-notify', () => { +describe('agents/spark-notify/tools', () => { it('emits strict parameter objects for spark notify tools', async () => { const { tools } = await createSparkNotifyTools({ onNoResponse: () => undefined, diff --git a/packages/core-agent/tsdown.config.ts b/packages/core-agent/tsdown.config.ts index 3701a3a8c..2477a68d2 100644 --- a/packages/core-agent/tsdown.config.ts +++ b/packages/core-agent/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ entry: [ 'src/index.ts', + 'src/agents/spark-command/index.ts', 'src/agents/spark-notify/index.ts', ], dts: true, diff --git a/packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts b/packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts index c04040f0d..22ea2823e 100644 --- a/packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts +++ b/packages/stage-ui/src/libs/providers/providers/azure-openai/index.test.ts @@ -1,10 +1,9 @@ import type { JsonSchema } from 'xsschema' +import { createSparkCommandTool } from '@proj-airi/core-agent/agents/spark-command' import { getDefinedProvider } from '@proj-airi/provider-inference' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createSparkCommandTool } from '../../../../tools/character/orchestrator/spark-command' - interface ChatRequestBody { tools: Array<{ function: { diff --git a/packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts b/packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts index 9770d405a..039c57da2 100644 --- a/packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts +++ b/packages/stage-ui/src/libs/providers/providers/openrouter-ai/index.test.ts @@ -2,11 +2,10 @@ import type { ChatRequestOptions } from '@proj-airi/provider-inference' import type { ChatProviderWithExtraOptions } from '@xsai-ext/providers/utils' import type { JsonSchema } from 'xsschema' +import { createSparkCommandTool } from '@proj-airi/core-agent/agents/spark-command' import { getDefinedProvider } from '@proj-airi/provider-inference' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createSparkCommandTool } from '../../../../tools/character/orchestrator/spark-command' - interface ChatRequestBody { tools: Array<{ function: { diff --git a/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.ts b/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.ts index 50ea43419..aae73a207 100644 --- a/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.ts @@ -2,9 +2,10 @@ import type { StreamOptions } from '@proj-airi/core-agent' import type { WebSocketEvents } from '@proj-airi/server-sdk' import type { Tool } from '@xsai/shared-chat' +import { createSparkCommandTool } from '@proj-airi/core-agent/agents/spark-command' import { uniqBy } from 'es-toolkit' -import { createSparkCommandTool, createWebSearchTools, debug, mcp } from '../../../tools' +import { createWebSearchTools, debug, mcp } from '../../../tools' import { useModsServerChannelStore } from '../../mods/api/channel-server' import { useWebSearchStore } from '../../modules/web-search' import { useLlmToolsStore } from './tools' diff --git a/packages/stage-ui/src/tools/character/index.ts b/packages/stage-ui/src/tools/character/index.ts deleted file mode 100644 index 0ca31b470..000000000 --- a/packages/stage-ui/src/tools/character/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 6a717acfd..000000000 --- a/packages/stage-ui/src/tools/character/orchestrator/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './spark-command' -export * from './spark-command-shared' -export * from './spark-notify' diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts deleted file mode 100644 index f240f4a10..000000000 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-notify.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type { - CreateSparkNotifyToolsOptions, - SparkNotifyCommandDraft, -} from '@proj-airi/core-agent/agents/spark-notify' - -export { - createSparkNotifyTools, - sparkNotifyCommandSchema, -} from '@proj-airi/core-agent/agents/spark-notify' diff --git a/packages/stage-ui/src/tools/index.ts b/packages/stage-ui/src/tools/index.ts index 5e4e5c2c8..a09f12511 100644 --- a/packages/stage-ui/src/tools/index.ts +++ b/packages/stage-ui/src/tools/index.ts @@ -1,4 +1,3 @@ -export * from './character' export * from './debug' export * from './mcp' export * from './web-search'