fix(stage-ui,stage-tamagotchi,core-agent): tool error should be handled
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
@@ -142,6 +144,7 @@ describe('useChatSyncStore authority ingest failures', async () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
MockBroadcastChannel.reset()
|
||||
vi.restoreAllMocks()
|
||||
|
||||
const activeSessionId = ref('session-1')
|
||||
const sessionMessages = ref<Record<string, Array<{ role: string, content: string }>>>({
|
||||
@@ -194,6 +197,7 @@ describe('useChatSyncStore authority ingest failures', async () => {
|
||||
* })
|
||||
*/
|
||||
it('stores command ingest errors in authority session history', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const store = useChatSyncStore()
|
||||
store.initialize('authority')
|
||||
|
||||
@@ -218,11 +222,55 @@ describe('useChatSyncStore authority ingest failures', async () => {
|
||||
expect(persistedMessages).toHaveLength(2)
|
||||
expect(persistedMessages[1]?.role).toBe('error')
|
||||
expect(persistedMessages[1]?.content).toContain('This model is not available in your region')
|
||||
expect(consoleError).toHaveBeenCalledWith('[chat-sync] command failed', expect.objectContaining({
|
||||
command: 'ingest',
|
||||
requestId: 'req-1',
|
||||
errorMessage: expect.stringContaining('This model is not available in your region'),
|
||||
payload: expect.objectContaining({
|
||||
text: 'hello',
|
||||
sessionId: 'session-1',
|
||||
}),
|
||||
}))
|
||||
|
||||
peer.close()
|
||||
store.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(store.requestIngest({ text: 'hello' })).rejects.toThrow(/timed out/i)
|
||||
* expect(console.error).toHaveBeenCalledWith('[chat-sync] command timed out waiting for authority response', expect.any(Object))
|
||||
*/
|
||||
it('logs follower command timeouts with request metadata', async () => {
|
||||
vi.useFakeTimers()
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const store = useChatSyncStore()
|
||||
store.initialize('follower')
|
||||
|
||||
const pending = store.requestIngest({
|
||||
text: 'hello timeout',
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
const expectedRejection = expect(pending).rejects.toThrow('Timed out waiting for chat authority response')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
|
||||
await expectedRejection
|
||||
expect(consoleError).toHaveBeenCalledWith('[chat-sync] command timed out waiting for authority response', expect.objectContaining({
|
||||
command: 'ingest',
|
||||
mode: 'follower',
|
||||
requestId: expect.any(String),
|
||||
errorMessage: 'Timed out waiting for chat authority response',
|
||||
payload: expect.objectContaining({
|
||||
text: 'hello timeout',
|
||||
sessionId: 'session-1',
|
||||
}),
|
||||
}))
|
||||
|
||||
store.dispose()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* it('replaces the last failed turn before retrying', async () => {
|
||||
|
||||
@@ -119,6 +119,44 @@ function resolveRetrySourceIndex(messages: ChatHistoryItem[], index: number): nu
|
||||
return -1
|
||||
}
|
||||
|
||||
function previewChatSyncPayload(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return payload
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>
|
||||
const text = typeof record.text === 'string' ? record.text : undefined
|
||||
|
||||
return {
|
||||
...record,
|
||||
text: text && text.length > 160 ? `${text.slice(0, 160)}...` : text,
|
||||
attachments: Array.isArray(record.attachments)
|
||||
? `[${record.attachments.length} attachment(s)]`
|
||||
: record.attachments,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs chat-sync failures at the BroadcastChannel boundary.
|
||||
*
|
||||
* Use when:
|
||||
* - A follower window times out waiting for the authority window
|
||||
* - The authority window fails while executing a forwarded chat command
|
||||
*
|
||||
* Expects:
|
||||
* - `details` only contains structured-clone-friendly diagnostic metadata
|
||||
*
|
||||
* Returns:
|
||||
* - Writes an error entry to the renderer console for postmortem debugging
|
||||
*/
|
||||
function logChatSyncError(message: string, error: unknown, details: Record<string, unknown>) {
|
||||
console.error(`[chat-sync] ${message}`, {
|
||||
...details,
|
||||
error,
|
||||
errorMessage: errorMessageFrom(error) ?? String(error),
|
||||
})
|
||||
}
|
||||
|
||||
export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () => {
|
||||
const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
const mode = ref<ChatSyncMode>('inactive')
|
||||
@@ -363,6 +401,15 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
|
||||
catch (error) {
|
||||
const errorMessage = errorMessageFrom(error) ?? 'Unknown chat sync command failure'
|
||||
|
||||
logChatSyncError('command failed', error, {
|
||||
mode: mode.value,
|
||||
authorityId: authorityId.value,
|
||||
requestId: message.requestId,
|
||||
senderId: message.senderId,
|
||||
command: message.command,
|
||||
payload: previewChatSyncPayload(message.payload),
|
||||
})
|
||||
|
||||
if (message.command === 'ingest')
|
||||
appendIngestErrorMessage(message.payload, errorMessage)
|
||||
|
||||
@@ -469,7 +516,16 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(message.requestId)
|
||||
reject(new Error('Timed out waiting for chat authority response'))
|
||||
const error = new Error('Timed out waiting for chat authority response')
|
||||
logChatSyncError('command timed out waiting for authority response', error, {
|
||||
mode: mode.value,
|
||||
authorityId: authorityId.value,
|
||||
requestId: message.requestId,
|
||||
senderId: message.senderId,
|
||||
command: message.command,
|
||||
payload: previewChatSyncPayload(message.payload),
|
||||
})
|
||||
reject(error)
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
pendingRequests.set(message.requestId, { resolve, reject, timeout })
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { resolveArtistryConfigFromStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installStrictToolSchemaMatchers } from '../testing/strict-tool-schema'
|
||||
|
||||
installStrictToolSchemaMatchers()
|
||||
|
||||
describe('image_journal config snapshot', () => {
|
||||
it('uses required nullable fields for strict provider schemas', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: {
|
||||
origin: 'http://localhost',
|
||||
},
|
||||
})
|
||||
|
||||
const { imageJournalTools } = await import('./image-journal')
|
||||
const tools = await imageJournalTools()
|
||||
|
||||
expect(tools).toSatisfyStrictToolSchemas()
|
||||
})
|
||||
|
||||
it('extracts plain values instead of leaking Ref objects', () => {
|
||||
const config = resolveArtistryConfigFromStore({
|
||||
activeProvider: { value: 'comfyui' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ResolvedArtistryConfig } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
import type { JsonSchema } from 'xsschema'
|
||||
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
@@ -7,8 +8,7 @@ import { artistryGenerateHeadless } from '@proj-airi/stage-shared'
|
||||
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { resolveArtistryConfigFromStore, useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import { tool } from '@xsai/tool'
|
||||
import { z } from 'zod'
|
||||
import { rawTool } from '@xsai/tool'
|
||||
|
||||
import { widgetsAdd } from '../../../../shared/eventa'
|
||||
|
||||
@@ -33,13 +33,41 @@ function getInvokers(): Invokers {
|
||||
return invokeCache
|
||||
}
|
||||
|
||||
const imageJournalParams = z.object({
|
||||
action: z.enum(['create', 'apply']).describe('Choose "create" to generate a new image, or "apply" to use an existing one.'),
|
||||
prompt: z.string().optional().describe('Description for the image (required for "create").'),
|
||||
title: z.string().optional().describe('Label for the entry (optional).'),
|
||||
query: z.string().optional().describe('Search term for existing images (required for "apply").'),
|
||||
mode: z.enum(['inline', 'widget', 'bg', 'bg_widget']).optional().describe('Display mode: "inline" (in chat), "widget" (overlay), "bg" (environment), or "bg_widget" (both). Defaults to character preference.'),
|
||||
})
|
||||
const imageJournalParams = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['create', 'apply'],
|
||||
description: 'Choose "create" to generate a new image, or "apply" to use an existing one.',
|
||||
},
|
||||
prompt: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Description for the image (required for "create").',
|
||||
},
|
||||
title: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Label for the entry (optional).',
|
||||
},
|
||||
query: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Search term for existing images (required for "apply").',
|
||||
},
|
||||
mode: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['inline', 'widget', 'bg', 'bg_widget', null],
|
||||
description: 'Display mode: "inline" (in chat), "widget" (overlay), "bg" (environment), or "bg_widget" (both). Defaults to character preference.',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'action',
|
||||
'prompt',
|
||||
'title',
|
||||
'query',
|
||||
'mode',
|
||||
],
|
||||
additionalProperties: false,
|
||||
} satisfies JsonSchema
|
||||
|
||||
async function executeCreateImageJournalEntry(params: { prompt?: string, title?: string, mode?: 'inline' | 'widget' | 'bg' | 'bg_widget' }) {
|
||||
if (!params.prompt?.trim())
|
||||
@@ -199,12 +227,12 @@ async function executeImageJournalAction(params: any) {
|
||||
}
|
||||
|
||||
const tools: Promise<Tool>[] = [
|
||||
tool({
|
||||
Promise.resolve(rawTool({
|
||||
name: 'image_journal',
|
||||
description: 'Manage AI-generated images. Use "create" to generate and display images. An optional "mode" (inline, widget, bg, bg_widget) can override the default character routing preference. Use "apply" to switch to an existing image from the journal.',
|
||||
execute: params => executeImageJournalAction(params),
|
||||
parameters: imageJournalParams,
|
||||
}),
|
||||
})),
|
||||
]
|
||||
|
||||
export const imageJournalTools = async () => Promise.all(tools)
|
||||
|
||||
@@ -9,8 +9,11 @@ import { promisify } from 'node:util'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../../../widgets/extension-ui/host'
|
||||
import { installStrictToolSchemaMatchers } from '../testing/strict-tool-schema'
|
||||
import { executeWidgetAction, normalizeComponentProps, widgetsTools } from './widgets'
|
||||
|
||||
installStrictToolSchemaMatchers()
|
||||
|
||||
const execFile = promisify(execFileCallback)
|
||||
const aihubmixApiKey = process.env.AIHUBMIX_API_KEY?.trim() || ''
|
||||
const hasAihubmixApiKey = Boolean(aihubmixApiKey)
|
||||
@@ -221,6 +224,7 @@ describe('widgets tool helpers', () => {
|
||||
// nullable, then requires every nested key while allowing optional constraints to
|
||||
// be expressed as `number | null`. That preserves the runtime behavior while
|
||||
// satisfying strict tool validators that compare `required` against `properties`.
|
||||
expect(stageWidgetsTool).toSatisfyStrictToolSchema()
|
||||
expect(windowSize).toBeDefined()
|
||||
expect(windowSize?.additionalProperties).toBe(false)
|
||||
expect(Object.keys(windowSize?.properties ?? {})).toEqual([
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { installStrictToolSchemaMatchers } from './strict-tool-schema'
|
||||
|
||||
installStrictToolSchemaMatchers()
|
||||
|
||||
function createTool(parameters: unknown): Tool {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'Test tool.',
|
||||
parameters,
|
||||
},
|
||||
} as Tool
|
||||
}
|
||||
|
||||
describe('strict tool schema matchers', () => {
|
||||
/**
|
||||
* @example
|
||||
* expect(tool).toSatisfyStrictToolSchema()
|
||||
*/
|
||||
it('accepts a strict provider-safe tool schema', () => {
|
||||
const tool = createTool({
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: ['string', 'null'],
|
||||
},
|
||||
},
|
||||
required: ['mode'],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(tool).toSatisfyStrictToolSchema()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(() => expect(tool).toSatisfyStrictToolSchema()).toThrow(/mode/)
|
||||
*/
|
||||
it('reports missing required keys with schema paths', () => {
|
||||
const tool = createTool({
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: ['string', 'null'],
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(() => expect(tool).toSatisfyStrictToolSchema()).toThrow(/test_tool\.parameters.*mode/)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect([tool]).toSatisfyStrictToolSchemas()
|
||||
*/
|
||||
it('checks a list of tools', () => {
|
||||
const tool = createTool({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect([tool]).toSatisfyStrictToolSchemas()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
import type { JsonSchema } from 'xsschema'
|
||||
|
||||
import { expect } from 'vitest'
|
||||
|
||||
interface StrictToolSchemaIssue {
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T = any> {
|
||||
toSatisfyStrictToolSchema: () => T
|
||||
toSatisfyStrictToolSchemas: () => T
|
||||
}
|
||||
interface AsymmetricMatchersContaining {
|
||||
toSatisfyStrictToolSchema: () => void
|
||||
toSatisfyStrictToolSchemas: () => void
|
||||
}
|
||||
}
|
||||
|
||||
function isSchemaRecord(value: unknown): value is JsonSchema {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function sorted(values: string[]): string[] {
|
||||
return [...values].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function collectSchemaIssues(schema: unknown, path: string, issues: StrictToolSchemaIssue[]): void {
|
||||
if (!isSchemaRecord(schema)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (schema.properties) {
|
||||
const propertyKeys = Object.keys(schema.properties)
|
||||
const required = Array.isArray(schema.required) ? schema.required.filter((value): value is string => typeof value === 'string') : []
|
||||
|
||||
if (!Array.isArray(schema.required)) {
|
||||
issues.push({
|
||||
path,
|
||||
message: '`required` must be supplied when `properties` is present.',
|
||||
})
|
||||
}
|
||||
else if (sorted(required).join('\0') !== sorted(propertyKeys).join('\0')) {
|
||||
const missing = propertyKeys.filter(key => !required.includes(key))
|
||||
const extra = required.filter(key => !propertyKeys.includes(key))
|
||||
issues.push({
|
||||
path,
|
||||
message: [
|
||||
'`required` must include every key in `properties`.',
|
||||
missing.length ? `Missing: ${missing.join(', ')}.` : '',
|
||||
extra.length ? `Extra: ${extra.join(', ')}.` : '',
|
||||
].filter(Boolean).join(' '),
|
||||
})
|
||||
}
|
||||
|
||||
if (schema.additionalProperties !== false) {
|
||||
issues.push({
|
||||
path,
|
||||
message: '`additionalProperties` must be false when `properties` is present.',
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(schema.properties)) {
|
||||
collectSchemaIssues(value, `${path}.${key}`, issues)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.items)) {
|
||||
schema.items.forEach((item, index) => collectSchemaIssues(item, `${path}.items[${index}]`, issues))
|
||||
}
|
||||
else if (schema.items) {
|
||||
collectSchemaIssues(schema.items, `${path}.items`, issues)
|
||||
}
|
||||
|
||||
for (const unionKey of ['anyOf', 'oneOf', 'allOf'] as const) {
|
||||
const schemas = schema[unionKey]
|
||||
if (Array.isArray(schemas)) {
|
||||
schemas.forEach((item, index) => collectSchemaIssues(item, `${path}.${unionKey}[${index}]`, issues))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects strict provider schema issues from one xsAI tool.
|
||||
*
|
||||
* Use when:
|
||||
* - Vitest checks need diagnostics instead of throwing immediately
|
||||
* - A provider rejects schemas that omit `required` keys or allow extra object properties
|
||||
*
|
||||
* Expects:
|
||||
* - `tool.function.parameters` contains the provider-facing JSON Schema
|
||||
*
|
||||
* Returns:
|
||||
* - A list of path-qualified issues; empty means the schema satisfies the local strict rules
|
||||
*/
|
||||
export function collectStrictToolSchemaIssues(tool: Tool): StrictToolSchemaIssue[] {
|
||||
const issues: StrictToolSchemaIssue[] = []
|
||||
collectSchemaIssues(tool.function.parameters, `${tool.function.name}.parameters`, issues)
|
||||
return issues
|
||||
}
|
||||
|
||||
function formatIssues(issues: StrictToolSchemaIssue[]): string {
|
||||
return issues.map(issue => `- ${issue.path}: ${issue.message}`).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs Vitest matchers for strict provider-facing tool schema checks.
|
||||
*
|
||||
* Use when:
|
||||
* - A test file wants `expect(tool).toSatisfyStrictToolSchema()`
|
||||
* - A test file wants `expect(tools).toSatisfyStrictToolSchemas()`
|
||||
*
|
||||
* Expects:
|
||||
* - Called before the matcher is used in the current Vitest worker
|
||||
*
|
||||
* Returns:
|
||||
* - Registers matchers on Vitest's `expect` object
|
||||
*/
|
||||
export function installStrictToolSchemaMatchers(): void {
|
||||
expect.extend({
|
||||
toSatisfyStrictToolSchema(received: Tool) {
|
||||
const issues = collectStrictToolSchemaIssues(received)
|
||||
|
||||
return {
|
||||
pass: issues.length === 0,
|
||||
message: () => issues.length
|
||||
? `Expected tool schema to satisfy strict provider rules:\n${formatIssues(issues)}`
|
||||
: 'Expected tool schema not to satisfy strict provider rules.',
|
||||
}
|
||||
},
|
||||
toSatisfyStrictToolSchemas(received: Tool[]) {
|
||||
const issues = received.flatMap(tool => collectStrictToolSchemaIssues(tool))
|
||||
|
||||
return {
|
||||
pass: issues.length === 0,
|
||||
message: () => issues.length
|
||||
? `Expected tool schemas to satisfy strict provider rules:\n${formatIssues(issues)}`
|
||||
: 'Expected tool schemas not to satisfy strict provider rules.',
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message, Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { streamFrom } from './llm-service'
|
||||
|
||||
const { streamTextMock } = vi.hoisted(() => ({
|
||||
streamTextMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@xsai/stream-text', () => ({
|
||||
streamText: streamTextMock,
|
||||
}))
|
||||
|
||||
vi.mock('@xsai/shared-chat', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@xsai/shared-chat')>()
|
||||
return {
|
||||
...actual,
|
||||
stepCountAtLeast: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const provider = {
|
||||
chat: () => ({
|
||||
baseURL: 'https://example.com/',
|
||||
}),
|
||||
} as unknown as ChatProvider
|
||||
|
||||
function createMockStreamResult(steps: Promise<unknown[]> = Promise.resolve([])) {
|
||||
return {
|
||||
steps,
|
||||
messages: Promise.resolve([]),
|
||||
usage: Promise.resolve(undefined),
|
||||
totalUsage: Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
describe('streamFrom tool error capture', () => {
|
||||
/**
|
||||
* @example
|
||||
* await streamFrom({ model, chatProvider, messages, options: { captureToolErrors: true } })
|
||||
*/
|
||||
it('keeps captureToolErrors internal while forwarding failed tool calls as tool-error events', async () => {
|
||||
let resolveSteps: ((steps: unknown[]) => void) | undefined
|
||||
const events: unknown[] = []
|
||||
const failingTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'play_chess',
|
||||
description: 'Start chess.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
execute: vi.fn(() => {
|
||||
throw new Error('Focus mode does not accept game-state mutation inputs.')
|
||||
}),
|
||||
} satisfies Tool
|
||||
|
||||
streamTextMock.mockImplementationOnce((options: {
|
||||
captureToolErrors?: boolean
|
||||
onEvent: (event: unknown) => Promise<void>
|
||||
tools?: Tool[]
|
||||
}) => {
|
||||
const steps = new Promise<unknown[]>((resolve) => {
|
||||
resolveSteps = resolve
|
||||
})
|
||||
|
||||
queueMicrotask(async () => {
|
||||
const result = await options.tools?.[0]?.execute({}, {
|
||||
messages: [],
|
||||
toolCallId: 'call-1',
|
||||
})
|
||||
|
||||
await options.onEvent({
|
||||
type: 'tool-result',
|
||||
args: {},
|
||||
result,
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'play_chess',
|
||||
})
|
||||
await options.onEvent({ type: 'finish', finishReason: 'stop' })
|
||||
resolveSteps?.([])
|
||||
})
|
||||
|
||||
return createMockStreamResult(steps)
|
||||
})
|
||||
|
||||
await streamFrom({
|
||||
model: 'model-a',
|
||||
chatProvider: provider,
|
||||
messages: [{ role: 'user', content: 'play chess' }] as Message[],
|
||||
options: {
|
||||
captureToolErrors: true,
|
||||
tools: [failingTool],
|
||||
onStreamEvent: (event) => {
|
||||
events.push(event)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const streamOptions = streamTextMock.mock.calls[0]?.[0]
|
||||
expect(streamOptions.captureToolErrors).toBeUndefined()
|
||||
expect(streamOptions.tools?.[0]).not.toBe(failingTool)
|
||||
expect(failingTool.execute).toHaveBeenCalledTimes(1)
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
type: 'tool-error',
|
||||
isError: true,
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'play_chess',
|
||||
result: expect.stringContaining('Focus mode does not accept game-state mutation inputs.'),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message } from '@xsai/shared-chat'
|
||||
import type { Message, Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type { StreamFromOptions, StreamOptions } from '../types/llm'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { stepCountAtLeast } from '@xsai/shared-chat'
|
||||
import { streamText } from '@xsai/stream-text'
|
||||
|
||||
@@ -46,6 +47,65 @@ async function resolveTools(options?: StreamOptions) {
|
||||
return tools ?? []
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& (error as { name?: unknown }).name === 'AbortError'
|
||||
}
|
||||
|
||||
function createCapturedToolErrorResult(toolName: string, error: unknown): string {
|
||||
return `Tool call error for "${toolName}": ${errorMessageFrom(error) ?? String(error)}`
|
||||
}
|
||||
|
||||
function withCapturedToolErrors(
|
||||
tools: Tool[],
|
||||
capturedToolErrorByCallId: Map<string, string>,
|
||||
): Tool[] {
|
||||
return tools.map(tool => ({
|
||||
...tool,
|
||||
execute: async (input, executeOptions) => {
|
||||
try {
|
||||
return await tool.execute(input, executeOptions)
|
||||
}
|
||||
catch (error) {
|
||||
if (isAbortError(error))
|
||||
throw error
|
||||
|
||||
const result = createCapturedToolErrorResult(tool.function.name, error)
|
||||
capturedToolErrorByCallId.set(executeOptions.toolCallId, result)
|
||||
return result
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveCapturedToolErrorEvent(
|
||||
event: unknown,
|
||||
capturedToolErrorByCallId: Map<string, string>,
|
||||
) {
|
||||
if (
|
||||
typeof event !== 'object'
|
||||
|| event === null
|
||||
|| (event as { type?: unknown }).type !== 'tool-result'
|
||||
|| typeof (event as { toolCallId?: unknown }).toolCallId !== 'string'
|
||||
) {
|
||||
return event
|
||||
}
|
||||
|
||||
const toolCallId = (event as { toolCallId: string }).toolCallId
|
||||
const result = capturedToolErrorByCallId.get(toolCallId)
|
||||
if (result == null)
|
||||
return event
|
||||
|
||||
capturedToolErrorByCallId.delete(toolCallId)
|
||||
return {
|
||||
...event,
|
||||
type: 'tool-error',
|
||||
isError: true,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamFrom({
|
||||
model,
|
||||
chatProvider,
|
||||
@@ -63,6 +123,10 @@ export async function streamFrom({
|
||||
const customTools = supportedTools ? await resolveTools(options) : []
|
||||
const mergedTools = supportedTools ? [...builtinTools, ...customTools] : []
|
||||
const tools = mergedTools.length > 0 ? mergedTools : undefined
|
||||
const capturedToolErrorByCallId = new Map<string, string>()
|
||||
const streamTools = options?.captureToolErrors && tools != null
|
||||
? withCapturedToolErrors(tools, capturedToolErrorByCallId)
|
||||
: tools
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
@@ -81,7 +145,8 @@ export async function streamFrom({
|
||||
|
||||
const onEvent = async (event: unknown) => {
|
||||
try {
|
||||
await options?.onStreamEvent?.(event as any)
|
||||
const streamEvent = resolveCapturedToolErrorEvent(event, capturedToolErrorByCallId)
|
||||
await options?.onStreamEvent?.(streamEvent as any)
|
||||
if (event && (event as any).type === 'finish') {
|
||||
const finishReason = (event as any).finishReason
|
||||
const waitingForToolRound = finishReason === 'tool_calls' || finishReason === 'tool-calls'
|
||||
@@ -104,10 +169,12 @@ export async function streamFrom({
|
||||
messages: sanitized,
|
||||
headers: options?.headers,
|
||||
stopWhen: stepCountAtLeast(10),
|
||||
tools,
|
||||
// NOTICE: Some OpenAI-compatible gateways reject the wire-level
|
||||
// `capture_tool_errors` parameter with 400 unknown_parameter.
|
||||
// Keep this unset here so the request remains provider-compatible.
|
||||
// NOTICE:
|
||||
// Do not pass xsAI's `captureToolErrors` option here. In the installed
|
||||
// @xsai/stream-text version, stream options are spread into the provider
|
||||
// chat body, so unknown runtime-only fields can be rejected upstream.
|
||||
// AIRI captures tool failures by wrapping local tool executors instead.
|
||||
tools: streamTools,
|
||||
onEvent,
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface StreamOptions {
|
||||
toolsCompatibility?: Map<string, boolean>
|
||||
supportsTools?: boolean
|
||||
waitForTools?: boolean
|
||||
captureToolErrors?: boolean
|
||||
tools?: Tool[] | (() => Promise<Tool[] | undefined>)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: '@proj-airi/core-agent',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -7,6 +7,14 @@ import { ref } from 'vue'
|
||||
|
||||
import { useChatOrchestratorStore } from './chat'
|
||||
|
||||
vi.hoisted(() => {
|
||||
;(globalThis as any).window = {
|
||||
location: {
|
||||
origin: 'http://localhost',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const llmStreamMock = vi.fn()
|
||||
const trackFirstMessageMock = vi.fn()
|
||||
const ingestContextMessageMock = vi.fn()
|
||||
@@ -147,6 +155,18 @@ vi.mock('./modules/consciousness', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./modules/airi-card', () => ({
|
||||
useAiriCardStore: () => ({
|
||||
activeCard: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./modules/artistry-autonomous', () => ({
|
||||
useAutonomousArtistryStore: () => ({
|
||||
runArtistTask: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const provider = {
|
||||
chat: () => ({ baseURL: 'https://example.com/' }),
|
||||
} as unknown as ChatProvider
|
||||
@@ -195,6 +215,7 @@ describe('chat orchestrator contract', () => {
|
||||
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, messages: Message[], options: any) => {
|
||||
composedMessages = messages
|
||||
expect(options.waitForTools).toBe(true)
|
||||
expect(options.captureToolErrors).toBe(true)
|
||||
|
||||
await options.onStreamEvent({ type: 'text-delta', text: 'hello' })
|
||||
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
|
||||
|
||||
@@ -438,6 +438,7 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
|
||||
// NOTICE: xsai stream may emit `finish` before tool steps continue, so keep waiting until
|
||||
// the final non-tool finish to avoid ending the chat turn with no assistant reply.
|
||||
waitForTools: true,
|
||||
captureToolErrors: true,
|
||||
onStreamEvent: async (event: StreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'tool-call':
|
||||
|
||||
Reference in New Issue
Block a user