refactor(core-agent): move spark-command agent from stage-ui to core-agent (#2528)

## Summary

Move `spark-command` and `spark-notify` agents from
`packages/stage-ui/src/tools/character/orchestrator/` to
`packages/core-agent/src/agents/` for better separation of concerns and
reusability across packages.

## Changes

- **Relocate spark-command agent** — moved `spark-command-shared.ts`,
`spark-command.ts`, and `spark-command.test.ts` to
`packages/core-agent/src/agents/spark-command/`
- **Relocate spark-notify test** — moved `spark-notify.test.ts` to
`packages/core-agent/src/agents/spark-notify/`
- **Add barrel export** — created
`packages/core-agent/src/agents/spark-command/index.ts` with re-exports
- **Update package.json** — added `./agents/spark-command` entry in
`packages/core-agent/package.json`
- **Update build config** — added `src/agents/spark-command/index.ts` to
`tsdown.config.ts` entry list
- **Consolidate schemas** — merged duplicate
`sparkNotifyCommandGuidanceSchema` into `sparkCommandGuidanceSchema`
- **Add JSDoc** — added `@example` blocks to all normalize functions in
`schema.ts`
- **Update imports** — provider tests and `tool-resolver.ts` now import
from `@proj-airi/core-agent/agents/spark-command`
- **Delete legacy shims** — removed `stage-ui/src/tools/character/`
re-export layer

## Verification

```bash
pnpm -F @proj-airi/core-agent typecheck
pnpm -F @proj-airi/stage-ui typecheck
pnpm -F @proj-airi/core-agent exec vitest run
pnpm lint
```
This commit is contained in:
Garfield Lee
2026-09-12 09:34:05 +08:00
committed by GitHub
parent 3fcae726c5
commit cf6ff23f7c
14 changed files with 117 additions and 41 deletions
+4
View File
@@ -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",
@@ -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'
@@ -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<typeof sparkCommandMetadataEntrySchema>[] | undefined,
): Record<string, string | number | boolean | null> | 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<typeof sparkCommandPersonaSchema>[] | undefined,
): Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'> | 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<typeof sparkCommandGuidanceOptionSchema>[],
) {
@@ -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<typeof sparkCommandContextSchema>['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`.
@@ -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,
@@ -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.
@@ -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,
+1
View File
@@ -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,
@@ -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: {
@@ -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: {
@@ -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'
@@ -1 +0,0 @@
export * from './orchestrator'
@@ -1,3 +0,0 @@
export * from './spark-command'
export * from './spark-command-shared'
export * from './spark-notify'
@@ -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'
-1
View File
@@ -1,4 +1,3 @@
export * from './character'
export * from './debug'
export * from './mcp'
export * from './web-search'