fix(stage-ui): tool definition missing strict & additionalProperties check

This commit is contained in:
Neko Ayaka
2026-01-09 05:37:56 +08:00
parent 99159f0635
commit 67e3d39de7
4 changed files with 94 additions and 13 deletions
@@ -7,17 +7,17 @@ import type { Mock } from 'vitest'
import type { UnwrapRef } from 'vue'
import type z from 'zod'
import type { sparkCommandSchema } from './character-orchestrator'
import type { StreamEvent } from './llm'
import type { AiriCard } from './modules'
import { createTestingPinia } from '@pinia/testing'
import { tool } from '@xsai/tool'
import { nanoid } from 'nanoid'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCharacterStore } from './character'
import { useCharacterOrchestratorStore } from './character-orchestrator'
import { sparkCommandSchema, useCharacterOrchestratorStore } from './character-orchestrator'
import { useLLM } from './llm'
import { useAiriCardStore, useConsciousnessStore } from './modules'
import { useProvidersStore } from './providers'
@@ -53,6 +53,54 @@ function mockedStore<TStoreDef extends () => unknown>(
return useStore() as any
}
function getObjectSchema(schema?: Record<string, any>) {
if (!schema)
return undefined
if (schema.type === 'object')
return schema
const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]
return candidates.find((candidate: Record<string, any>) => candidate?.type === 'object')
}
function getArraySchema(schema?: Record<string, any>) {
if (!schema)
return undefined
if (schema.type === 'array')
return schema
const candidates = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]
return candidates.find((candidate: Record<string, any>) => candidate?.type === 'array')
}
describe('sparkCommandSchema', () => {
it('emits strict objects in the json schema', async () => {
const sparkTool = await tool({
name: 'builtIn_sparkCommand',
description: 'test',
parameters: sparkCommandSchema,
execute: async () => undefined,
})
const schema = sparkTool.function.parameters as Record<string, any>
const commandsSchema = getArraySchema(schema.properties?.commands)
const commandItemSchema = getObjectSchema(commandsSchema?.items)
const guidanceSchema = getObjectSchema(commandItemSchema?.properties?.guidance)
const personaSchema = getArraySchema(guidanceSchema?.properties?.persona)
const personaItemSchema = getObjectSchema(personaSchema?.items)
const optionsSchema = getArraySchema(guidanceSchema?.properties?.options)
const optionsItemSchema = getObjectSchema(optionsSchema?.items)
expect(schema.additionalProperties).toBe(false)
expect(commandItemSchema?.additionalProperties).toBe(false)
expect(guidanceSchema?.additionalProperties).toBe(false)
expect(personaItemSchema?.additionalProperties).toBe(false)
expect(optionsItemSchema?.additionalProperties).toBe(false)
})
})
describe('store character-orchestrator', () => {
beforeEach(() => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
@@ -55,7 +55,7 @@ export const sparkCommandSchema = z.object({
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".'),
})).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.'),
}).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.'),
@@ -65,10 +65,10 @@ export const sparkCommandSchema = z.object({
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.'),
})),
}).nullable().describe('Guidance for the sub-agent on how to interpret and execute the command with given context, persona settings, and reasoning.'),
})).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()),
}).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>
@@ -97,7 +97,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
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({}),
parameters: z.object({}).strict(),
execute: async (_payload) => {
noResponse = true
return 'AIRI System: Acknowledged, no response or action will be processed.'
+33
View File
@@ -0,0 +1,33 @@
import type { JsonSchema } from 'xsschema'
import { describe, expect, it } from 'vitest'
import { mcp } from './mcp'
describe('tools mcp schema', () => {
it('emits strict parameter objects', async () => {
const tools = await mcp()
const toolNames = [
'mcp_list_tools',
'mcp_connect_server',
'mcp_disconnect_server',
'mcp_call_tool',
]
for (const name of toolNames) {
const tool = tools.find(entry => entry.function.name === name)
expect(tool, `missing tool: ${name}`).toBeDefined()
expect(tool?.function.parameters.additionalProperties).toBe(false)
}
})
it('keeps mcp_call_tool parameters items strict', async () => {
const tools = await mcp()
const callTool = tools.find(entry => entry.function.name === 'mcp_call_tool')
expect(callTool).toBeDefined()
const items = ((callTool?.function.parameters as JsonSchema).properties?.parameters as any)?.items
expect(items).toBeDefined()
expect(items?.additionalProperties).toBe(false)
})
})
+5 -5
View File
@@ -9,7 +9,7 @@ const tools = [
execute: async (_, __) => {
return await listTools()
},
parameters: z.object({}),
parameters: z.object({}).strict(),
}),
tool({
name: 'mcp_connect_server',
@@ -21,7 +21,7 @@ const tools = [
parameters: z.object({
command: z.string().describe('The command to connect to the MCP server'),
args: z.array(z.string()).describe('The arguments to pass to the MCP server'),
}),
}).strict(),
}),
tool({
name: 'mcp_disconnect_server',
@@ -30,7 +30,7 @@ const tools = [
await disconnectServer()
return 'success'
},
parameters: z.object({}),
parameters: z.object({}).strict(),
}),
tool({
name: 'mcp_call_tool',
@@ -50,8 +50,8 @@ const tools = [
name: z.string().describe('The name of the tool to call'),
parameters: z.array(z.object({
name: z.string().describe('The name of the parameter'),
value: z.union([z.string(), z.number(), z.boolean(), z.object({})]).describe('The value of the parameter, it can be a string, a number, a boolean, or an object'),
})).describe('The parameters to pass to the tool'),
value: z.union([z.string(), z.number(), z.boolean(), z.object({}).strict()]).describe('The value of the parameter, it can be a string, a number, a boolean, or an object'),
}).strict()).describe('The parameters to pass to the tool'),
}),
}),
]