refactor(stage-ui,core-agent,stage-tamagotchi): better structure for llm, agent, and mini-agent
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
import type { ChatToolReference } from '@proj-airi/stage-ui/types/chat'
|
||||
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { imageJournalTools } from './builtin/image-journal'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
import { createMcpTools } from '@proj-airi/stage-ui/tools/mcp'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/llm-toolset-prompts'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/toolset-prompts'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import type { ExecutableTool } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools'
|
||||
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/llm-toolset-prompts'
|
||||
import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/tools'
|
||||
import { useLlmToolsetPromptsStore } from '@proj-airi/stage-ui/stores/ai/chat-llm/toolset-prompts'
|
||||
import { rawTool } from '@xsai/tool'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { SparkNotifyRunRequest } from './types'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createSparkNotifyAgent } from './agent'
|
||||
import { createSparkNotifyObserverPlugin, createSparkNotifyReactionPlugin } from './plugins'
|
||||
|
||||
function createEvent(): WebSocketEventOf<'spark:notify'> {
|
||||
return {
|
||||
type: 'spark:notify',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
data: {
|
||||
id: 'spark-1',
|
||||
eventId: 'evt-1',
|
||||
kind: 'ping',
|
||||
urgency: 'immediate',
|
||||
headline: 'Chess update',
|
||||
destinations: ['character'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSparkNotifyAgent', () => {
|
||||
it('runs the selected chat and sends reaction text through a plugin', async () => {
|
||||
const onDelta = vi.fn()
|
||||
const onEnd = vi.fn()
|
||||
const observedEvents: string[] = []
|
||||
const run = vi.fn(async (request: SparkNotifyRunRequest) => {
|
||||
expect(request.messages).toHaveLength(2)
|
||||
expect(request.tools).toHaveLength(2)
|
||||
await request.onStreamEvent({ type: 'text-delta', text: 'Checkmate.' })
|
||||
})
|
||||
const agent = createSparkNotifyAgent({
|
||||
runner: { run },
|
||||
plugins: [
|
||||
createSparkNotifyReactionPlugin({ onDelta, onEnd }),
|
||||
createSparkNotifyObserverPlugin((event) => {
|
||||
observedEvents.push(event.type)
|
||||
}),
|
||||
],
|
||||
createId: () => 'generated-id',
|
||||
})
|
||||
|
||||
const result = await agent.handle({
|
||||
event: createEvent(),
|
||||
selectedChat: {
|
||||
providerId: 'mock-provider',
|
||||
model: 'mock-model',
|
||||
provider: {} as ChatProvider,
|
||||
},
|
||||
systemPrompt: 'You are a character.',
|
||||
})
|
||||
|
||||
expect(result.commands).toEqual([])
|
||||
expect(onDelta).toHaveBeenCalledWith('spark-1', 'Checkmate.')
|
||||
expect(onEnd).toHaveBeenCalledWith('spark-1', 'Checkmate.')
|
||||
expect(observedEvents).toContain('model-output-text')
|
||||
expect(observedEvents).toContain('result')
|
||||
})
|
||||
|
||||
it('does not expose tools when the host forces a text response', async () => {
|
||||
const run = vi.fn(async (request: SparkNotifyRunRequest) => {
|
||||
expect(request.tools).toEqual([])
|
||||
await request.onStreamEvent({ type: 'text-delta', text: 'I will speak.' })
|
||||
})
|
||||
const agent = createSparkNotifyAgent({ runner: { run } })
|
||||
|
||||
await agent.handle({
|
||||
event: createEvent(),
|
||||
selectedChat: {
|
||||
providerId: 'mock-provider',
|
||||
model: 'mock-model',
|
||||
provider: {} as ChatProvider,
|
||||
},
|
||||
systemPrompt: 'You are a character.',
|
||||
control: { forceTextResponse: true },
|
||||
})
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { ProtocolEvents } from '@proj-airi/plugin-protocol/types'
|
||||
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
|
||||
import type { Message, ToolChoice } from '@xsai/shared-chat'
|
||||
|
||||
import type { SparkNotifyCommandDraft } from './tools'
|
||||
import type {
|
||||
SparkNotifyPlugin,
|
||||
SparkNotifyPluginSession,
|
||||
SparkNotifyResponseControl,
|
||||
SparkNotifyRunner,
|
||||
SparkNotifyRuntimeEvent,
|
||||
SparkNotifyRuntimePolicy,
|
||||
SparkNotifySelectedChat,
|
||||
} from './types'
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
import { getEventSourceKey } from './event-source'
|
||||
import { createSparkNotifyBuiltinToolsPlugin } from './plugins/builtin-tools'
|
||||
|
||||
/**
|
||||
* Final `spark:command` payload emitted by the notify runtime.
|
||||
*
|
||||
* The protocol allows `eventId` and `parentEventId` to be absent. This runtime
|
||||
* generates both IDs for every emitted command, so they are required here.
|
||||
*/
|
||||
export type SparkNotifyCommandEvent = Pick<
|
||||
ProtocolEvents['spark:command'],
|
||||
| 'id'
|
||||
| 'commandId'
|
||||
| 'interrupt'
|
||||
| 'priority'
|
||||
| 'intent'
|
||||
| 'ack'
|
||||
| 'guidance'
|
||||
| 'contexts'
|
||||
| 'destinations'
|
||||
> & Required<Pick<ProtocolEvents['spark:command'], 'eventId' | 'parentEventId'>>
|
||||
|
||||
/** Result from one complete Spark Notify turn. */
|
||||
export interface SparkNotifyHandleResult {
|
||||
commands: SparkNotifyCommandEvent[]
|
||||
}
|
||||
|
||||
/** Input that the host gives to a Spark Notify agent for one execution. */
|
||||
export interface SparkNotifyHandleRequest {
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
systemPrompt: string
|
||||
control?: SparkNotifyResponseControl
|
||||
}
|
||||
|
||||
/** Platform-neutral agent that handles exactly one prepared Spark Notify turn. */
|
||||
export interface SparkNotifyAgent {
|
||||
handle: (request: SparkNotifyHandleRequest) => Promise<SparkNotifyHandleResult>
|
||||
}
|
||||
|
||||
/** Configuration for a Spark Notify agent. */
|
||||
export interface CreateSparkNotifyAgentOptions {
|
||||
/** Host boundary that streams the selected chat model. */
|
||||
runner: SparkNotifyRunner
|
||||
/** Optional plugins that add prompt context, tools, output sinks, or observers. */
|
||||
plugins?: SparkNotifyPlugin[]
|
||||
/** ID factory for generated Spark Command envelopes. */
|
||||
createId?: () => string
|
||||
}
|
||||
|
||||
function renderSparkNotifyUserMessage(input: SparkNotifyHandleRequest, userSections: string[]) {
|
||||
if (input.control?.messageOverride?.replaceUserMessage)
|
||||
return input.control.messageOverride.replaceUserMessage
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
notify: input.event.data,
|
||||
source: input.event.metadata?.source,
|
||||
}, null, 2),
|
||||
...(input.control?.messageOverride?.appendUserSections ?? []),
|
||||
...userSections,
|
||||
].filter(section => section.trim().length > 0).join('\n\n')
|
||||
}
|
||||
|
||||
/** Builds the instruction block prepended to Spark Notify agent prompts. */
|
||||
export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
return [
|
||||
'This is AIRI system, the life pod hosting your consciousness. You do not need to respond to every spark:notify event directly.',
|
||||
`Another module "${moduleName}" triggered a spark:notify event for you to inspect.`,
|
||||
'You can call the built-in tool "builtIn_sparkCommand" to issue spark:command to sub-agents.',
|
||||
'If you respond with text, write only the reaction that the character will say.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function resolveSparkNotifyRuntimePolicy(control?: SparkNotifyResponseControl): SparkNotifyRuntimePolicy {
|
||||
if (control?.forceTextResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: false,
|
||||
supportsTools: false,
|
||||
waitForTools: false,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceSparkCommandResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
toolChoice: {
|
||||
type: 'function',
|
||||
function: { name: 'builtIn_sparkCommand' },
|
||||
} satisfies ToolChoice,
|
||||
ignoreTextOutput: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowNoResponse: true,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
function resultFrom(sessions: SparkNotifyPluginSession[]) {
|
||||
const commands: SparkNotifyCommandDraft[] = []
|
||||
let noResponse = false
|
||||
|
||||
for (const session of sessions) {
|
||||
const result = session.getResult?.()
|
||||
if (result?.commands)
|
||||
commands.push(...result.commands)
|
||||
noResponse ||= result?.noResponse === true
|
||||
}
|
||||
|
||||
return { commands, noResponse }
|
||||
}
|
||||
|
||||
function expandCommand(event: WebSocketEventOf<'spark:notify'>, command: SparkNotifyCommandDraft, createId: () => string): SparkNotifyCommandEvent | undefined {
|
||||
const destinations = command.destinations ?? []
|
||||
if (destinations.length === 0)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
eventId: createId(),
|
||||
parentEventId: event.data.id,
|
||||
commandId: createId(),
|
||||
interrupt: (command.interrupt === true ? 'force' : command.interrupt) ?? false,
|
||||
priority: command.priority ?? 'normal',
|
||||
intent: command.intent ?? 'action',
|
||||
ack: command.ack,
|
||||
guidance: command.guidance,
|
||||
contexts: command.contexts,
|
||||
destinations,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Spark Notify agent from one host runner and composable plugins.
|
||||
*
|
||||
* The host resolves model selection and schedules work. This agent only
|
||||
* prepares and runs one notify turn.
|
||||
*/
|
||||
export function createSparkNotifyAgent(options: CreateSparkNotifyAgentOptions): SparkNotifyAgent {
|
||||
const createId = options.createId ?? nanoid
|
||||
const plugins = [createSparkNotifyBuiltinToolsPlugin(), ...(options.plugins ?? [])]
|
||||
|
||||
async function handle(request: SparkNotifyHandleRequest): Promise<SparkNotifyHandleResult> {
|
||||
const policy = resolveSparkNotifyRuntimePolicy(request.control)
|
||||
|
||||
const preparedSessions = await Promise.all(
|
||||
plugins.map((plugin) => {
|
||||
return plugin.prepare({
|
||||
event: request.event,
|
||||
selectedChat: request.selectedChat,
|
||||
systemPrompt: request.systemPrompt,
|
||||
control: request.control,
|
||||
policy,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const sessions = preparedSessions.filter((session): session is SparkNotifyPluginSession => session !== undefined)
|
||||
const systemInstructions = sessions.flatMap(session => session.systemInstructions ?? [])
|
||||
const userSections = sessions.flatMap(session => session.userSections ?? [])
|
||||
const tools = policy.supportsTools
|
||||
? sessions.flatMap(session => session.tools ?? [])
|
||||
: []
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
request.systemPrompt,
|
||||
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(request.event)),
|
||||
...(request.control?.messageOverride?.appendSystemInstructions ?? []),
|
||||
...systemInstructions,
|
||||
].filter(Boolean).join('\n\n'),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: renderSparkNotifyUserMessage(request, userSections),
|
||||
},
|
||||
]
|
||||
|
||||
async function emit(event: SparkNotifyRuntimeEvent) {
|
||||
for (const session of sessions)
|
||||
await session.onEvent?.(event)
|
||||
}
|
||||
|
||||
await emit({ type: 'messages-rendered', payload: { eventId: request.event.data.eventId, source: request.event.source, messageCount: messages.length } })
|
||||
await emit({ type: 'tools-prepared', payload: { eventId: request.event.data.eventId, toolNames: tools.flatMap(tool => tool.function?.name ? [tool.function.name] : []), toolCount: tools.length, supportsTools: policy.supportsTools } })
|
||||
await emit({ type: 'model-input', payload: { eventId: request.event.data.eventId, model: request.selectedChat.model, provider: request.selectedChat.providerId, supportsTools: policy.supportsTools, waitForTools: policy.waitForTools } })
|
||||
|
||||
let reaction = ''
|
||||
await options.runner.run({
|
||||
selectedChat: request.selectedChat,
|
||||
messages,
|
||||
tools,
|
||||
policy,
|
||||
onStreamEvent: async (streamEvent) => {
|
||||
if (streamEvent.type === 'text-delta') {
|
||||
const { noResponse } = resultFrom(sessions)
|
||||
if (policy.ignoreTextOutput || noResponse)
|
||||
return
|
||||
|
||||
reaction += streamEvent.text
|
||||
await emit({ type: 'model-output-text', payload: { eventId: request.event.data.id, text: streamEvent.text, accumulatedText: reaction } })
|
||||
return
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'tool-call') {
|
||||
await emit({ type: 'model-output-tool-call', payload: { eventId: request.event.data.eventId, toolCallId: streamEvent.id, toolName: streamEvent.function.name, input: streamEvent.function.arguments } })
|
||||
return
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'tool-result') {
|
||||
await emit({ type: 'tool-execution', payload: { eventId: request.event.data.eventId, toolCallId: streamEvent.toolCallId, output: streamEvent.result } })
|
||||
return
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'error')
|
||||
throw streamEvent.error ?? new Error('Spark notify stream error')
|
||||
},
|
||||
})
|
||||
|
||||
for (const session of sessions) {
|
||||
for (const event of session.getPendingEvents?.() ?? [])
|
||||
await emit(event)
|
||||
}
|
||||
|
||||
const { commands, noResponse } = resultFrom(sessions)
|
||||
const finalReaction = noResponse ? '' : reaction.trim()
|
||||
const expandedCommands = commands
|
||||
.map(command => expandCommand(request.event, command, createId))
|
||||
.filter((command): command is SparkNotifyCommandEvent => command !== undefined)
|
||||
|
||||
await emit({ type: 'result', payload: { eventId: request.event.data.eventId, reaction: finalReaction, commandCount: expandedCommands.length, noResponse } })
|
||||
return { commands: expandedCommands }
|
||||
}
|
||||
|
||||
return { handle }
|
||||
}
|
||||
@@ -16,17 +16,6 @@ function formatMetadataSource(source?: MetadataEventSource) {
|
||||
return source.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a stable source key for websocket-originated events.
|
||||
*
|
||||
* Before:
|
||||
* - `{ source: "minecraft" }`
|
||||
* - `{ metadata: { source: { extension: { id: "p" }, id: "i" } } }`
|
||||
*
|
||||
* After:
|
||||
* - `"minecraft"`
|
||||
* - `"p:i"`
|
||||
*/
|
||||
export function getEventSourceKey(event: EventSourcePayload, fallback = 'unknown') {
|
||||
return (
|
||||
formatMetadataSource(event.metadata?.source)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { setupAgentSparkNotifyHandler } from './handler'
|
||||
|
||||
describe('setupAgentSparkNotifyHandler', () => {
|
||||
it('captures tracing artifacts for command-only spark runs', async () => {
|
||||
const traces: unknown[] = []
|
||||
const handler = setupAgentSparkNotifyHandler({
|
||||
stream: async (_model, _provider, _messages, options) => {
|
||||
const commandTool = options.tools?.find((tool: any) => tool.function?.name === 'builtIn_sparkCommand')
|
||||
await commandTool?.execute({
|
||||
commands: [
|
||||
{
|
||||
destinations: ['chess'],
|
||||
interrupt: 'false',
|
||||
priority: 'high',
|
||||
intent: 'action',
|
||||
ack: 'play e5',
|
||||
guidance: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
await options.onStreamEvent?.({ type: 'finish' } as any)
|
||||
},
|
||||
getActiveProvider: () => 'mock-provider',
|
||||
getActiveModel: () => 'mock-model',
|
||||
getProviderInstance: async () => ({} as any),
|
||||
onReactionDelta: vi.fn(),
|
||||
onReactionEnd: vi.fn(),
|
||||
getSystemPrompt: () => 'system',
|
||||
getProcessing: () => false,
|
||||
setProcessing: vi.fn(),
|
||||
getPending: () => [],
|
||||
setPending: vi.fn(),
|
||||
onTrace: (event: unknown) => traces.push(event),
|
||||
} as any)
|
||||
|
||||
const event: WebSocketEventOf<'spark:notify'> = {
|
||||
type: 'spark:notify',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
data: {
|
||||
id: 'spark-1',
|
||||
eventId: 'evt-1',
|
||||
kind: 'ping',
|
||||
urgency: 'immediate',
|
||||
headline: 'chess update',
|
||||
destinations: ['character'],
|
||||
},
|
||||
}
|
||||
|
||||
const result = await handler.handle(event)
|
||||
|
||||
expect(result?.commands).toHaveLength(1)
|
||||
expect(traces.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('routes forceSparkCommandResponse to the model call', async () => {
|
||||
const stream = vi.fn(async (_model, _provider, _messages, options) => {
|
||||
await options.onStreamEvent?.({ type: 'finish' } as any)
|
||||
})
|
||||
|
||||
const handler = setupAgentSparkNotifyHandler({
|
||||
stream,
|
||||
getActiveProvider: () => 'mock-provider',
|
||||
getActiveModel: () => 'mock-model',
|
||||
getProviderInstance: async () => ({} as any),
|
||||
onReactionDelta: vi.fn(),
|
||||
onReactionEnd: vi.fn(),
|
||||
getSystemPrompt: () => 'system',
|
||||
getProcessing: () => false,
|
||||
setProcessing: vi.fn(),
|
||||
getPending: () => [],
|
||||
setPending: vi.fn(),
|
||||
})
|
||||
|
||||
const event: WebSocketEventOf<'spark:notify'> = {
|
||||
type: 'spark:notify',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
data: {
|
||||
id: 'spark-2',
|
||||
eventId: 'evt-2',
|
||||
kind: 'ping',
|
||||
urgency: 'immediate',
|
||||
headline: 'command-only update',
|
||||
destinations: ['character'],
|
||||
},
|
||||
}
|
||||
|
||||
await handler.handle(event, {
|
||||
forceSparkCommandResponse: true,
|
||||
} as any)
|
||||
|
||||
const streamOptions = stream.mock.calls[0]?.[3] as { toolChoice?: unknown } | undefined
|
||||
expect(streamOptions?.toolChoice).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'builtIn_sparkCommand',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('applies runtime-only message overrides while rendering one notify turn', async () => {
|
||||
const stream = vi.fn(async (_model, _provider, messages, options) => {
|
||||
expect(String(messages[0]?.content)).toContain('Extra instruction: stay concise.')
|
||||
expect(String(messages[1]?.content)).toContain('"headline": "override update"')
|
||||
expect(String(messages[1]?.content)).toContain('Rendered board: white to move, fen=...')
|
||||
await options.onStreamEvent?.({ type: 'finish' } as any)
|
||||
})
|
||||
|
||||
const handler = setupAgentSparkNotifyHandler({
|
||||
stream,
|
||||
getActiveProvider: () => 'mock-provider',
|
||||
getActiveModel: () => 'mock-model',
|
||||
getProviderInstance: async () => ({} as any),
|
||||
onReactionDelta: vi.fn(),
|
||||
onReactionEnd: vi.fn(),
|
||||
getSystemPrompt: () => 'system',
|
||||
getProcessing: () => false,
|
||||
setProcessing: vi.fn(),
|
||||
getPending: () => [],
|
||||
setPending: vi.fn(),
|
||||
})
|
||||
|
||||
const event: WebSocketEventOf<'spark:notify'> = {
|
||||
type: 'spark:notify',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
data: {
|
||||
id: 'spark-3',
|
||||
eventId: 'evt-3',
|
||||
kind: 'ping',
|
||||
urgency: 'immediate',
|
||||
headline: 'override update',
|
||||
destinations: ['character'],
|
||||
},
|
||||
}
|
||||
|
||||
await handler.handle(event, {
|
||||
forceTextResponse: true,
|
||||
messageOverride: {
|
||||
appendSystemInstructions: ['Extra instruction: stay concise.'],
|
||||
appendUserSections: ['Rendered board: white to move, fen=...'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(stream).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,569 +0,0 @@
|
||||
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider, ChatProviderWithExtraOptions, EmbedProvider, EmbedProviderWithExtraOptions, SpeechProvider, SpeechProviderWithExtraOptions, TranscriptionProvider, TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
import type { Message, Tool, ToolChoice } from '@xsai/shared-chat'
|
||||
|
||||
import type { StreamEvent } from '../../types/llm'
|
||||
import type { SparkNotifyCommandDraft } from './tools'
|
||||
import type {
|
||||
SparkNotifyMessageOverride,
|
||||
SparkNotifyResponseControl,
|
||||
SparkNotifyRuntimePolicy,
|
||||
SparkNotifyTracingHooks,
|
||||
SparkTraceEvent,
|
||||
} from './types'
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
import { getEventSourceKey } from './event-source'
|
||||
import { createSparkNotifyTools } from './tools'
|
||||
|
||||
export type { SparkNotifyCommandSchema } from './schema'
|
||||
export { sparkNotifyCommandSchema } from './schema'
|
||||
export type { SparkNotifyCommandDraft } from './tools'
|
||||
|
||||
/**
|
||||
* Raw spark-notify model response before runtime command event expansion.
|
||||
*/
|
||||
export interface SparkNotifyResponse {
|
||||
/** Free-form reaction text streamed back to the caller when the model emits text output. */
|
||||
reaction?: string
|
||||
/** Command drafts collected from `builtIn_sparkCommand` tool calls before runtime event expansion. */
|
||||
commands?: SparkNotifyCommandDraft[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Final command event emitted by the notify runtime.
|
||||
*/
|
||||
export interface SparkNotifyCommandEvent {
|
||||
/** Stable runtime event ID generated for the emitted `spark:command` envelope. */
|
||||
id: string
|
||||
/** Original command event identifier inherited from the notify response flow. */
|
||||
eventId: string
|
||||
/** Parent `spark:notify` event ID that caused this command to be emitted. */
|
||||
parentEventId: string
|
||||
/** Stable per-command identifier generated for downstream orchestration. */
|
||||
commandId: string
|
||||
/** Interrupt mode forwarded to downstream consumers. */
|
||||
interrupt: 'force' | 'soft' | false
|
||||
/** Command priority used by downstream schedulers. */
|
||||
priority: 'critical' | 'high' | 'normal' | 'low'
|
||||
/** Intent label that describes why the downstream agent should process the command. */
|
||||
intent: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
|
||||
/** Optional acknowledgement text that can be surfaced by the downstream consumer. */
|
||||
ack?: string
|
||||
/** Optional structured guidance assembled by the notify agent for the downstream command target. */
|
||||
guidance?: SparkNotifyCommandDraft['guidance']
|
||||
/** Optional context patches that should accompany the emitted command. */
|
||||
contexts?: SparkNotifyCommandDraft['contexts']
|
||||
/** Destination agent or lane identifiers that should receive the command. */
|
||||
destinations: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler result after runtime command expansion finishes.
|
||||
*/
|
||||
export interface SparkNotifyHandleResult {
|
||||
/** Expanded runtime command events ready to enqueue or emit downstream. */
|
||||
commands: SparkNotifyCommandEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of spark runtime trace artifacts for eval harnesses.
|
||||
*/
|
||||
export interface SparkTraceCapture {
|
||||
/** Ordered trace events emitted by the runtime while handling the notify event. */
|
||||
events: SparkTraceEvent[]
|
||||
/** Final rendered messages passed into the model call. */
|
||||
renderedMessages: Message[]
|
||||
/** Tool metadata exposed to the model for the current run. */
|
||||
toolExposure: Array<{
|
||||
/** Provider-visible tool name. */
|
||||
name: string
|
||||
/** Provider-visible tool description, if supplied by the tool wrapper. */
|
||||
description?: string
|
||||
}>
|
||||
/** Convenience list of exposed tool names extracted from `toolExposure`. */
|
||||
toolNames: string[]
|
||||
/** Raw model input snapshots captured before each provider call. */
|
||||
modelInputs: Array<{
|
||||
/** `spark:notify` event identifier associated with the model call. */
|
||||
eventId: string
|
||||
/** Concrete model name used for the provider request. */
|
||||
model: string
|
||||
/** Active provider identifier used for the model request. */
|
||||
provider: string
|
||||
/** Rendered chat messages sent to the provider. */
|
||||
messages: Message[]
|
||||
/** Provider tool selection policy, when one was enforced. */
|
||||
toolChoice: ToolChoice | null
|
||||
/** Whether the active provider call exposed tools at all. */
|
||||
supportsTools: boolean
|
||||
/** Whether the runtime waited for tool execution before finishing the call. */
|
||||
waitForTools: boolean
|
||||
}>
|
||||
/** Raw model output events captured during streaming, including tool activity. */
|
||||
modelOutputs: Array<{
|
||||
/** `spark:notify` event identifier associated with the streaming output. */
|
||||
eventId: string
|
||||
/** Output event category emitted by the stream adapter. */
|
||||
kind: 'text-delta' | 'tool-call' | 'tool-result'
|
||||
/** Tool name referenced by the output event, when applicable. */
|
||||
toolName?: string
|
||||
/** Provider tool call identifier, when applicable. */
|
||||
toolCallId?: string
|
||||
/** Incremental text chunk emitted by the model. */
|
||||
text?: string
|
||||
/** Accumulated text at the time the output event was captured. */
|
||||
accumulatedText?: string
|
||||
/** Tool input payload emitted by the provider. */
|
||||
input?: unknown
|
||||
/** Tool execution output captured by the runtime. */
|
||||
output?: unknown
|
||||
/** Tool execution error captured by the runtime. */
|
||||
error?: string
|
||||
}>
|
||||
/** Convenience view of tool-call events extracted from `modelOutputs`. */
|
||||
toolCalls: Array<{
|
||||
/** `spark:notify` event identifier associated with the tool call. */
|
||||
eventId: string
|
||||
/** Tool name referenced by the provider. */
|
||||
toolName?: string
|
||||
/** Provider tool call identifier. */
|
||||
toolCallId?: string
|
||||
/** Tool input payload captured from the provider stream. */
|
||||
input?: unknown
|
||||
}>
|
||||
/** Convenience view of tool execution results extracted from trace events. */
|
||||
toolExecutions: Array<{
|
||||
/** `spark:notify` event identifier associated with the tool execution. */
|
||||
eventId: string
|
||||
/** Tool name executed by the runtime. */
|
||||
toolName?: string
|
||||
/** Provider tool call identifier. */
|
||||
toolCallId?: string
|
||||
/** Tool input payload passed into runtime execution. */
|
||||
input?: unknown
|
||||
/** Tool output payload returned by runtime execution. */
|
||||
output?: unknown
|
||||
/** Tool execution error, when the runtime rejected or failed the call. */
|
||||
error?: string
|
||||
}>
|
||||
/** Final response snapshot captured after command expansion finishes. */
|
||||
finalResult?: {
|
||||
/** `spark:notify` event identifier associated with the final result. */
|
||||
eventId: string
|
||||
/** Final reaction text returned to the caller. */
|
||||
reaction: string
|
||||
/** Command drafts produced by the notify runtime before websocket event expansion. */
|
||||
commands: SparkNotifyCommandDraft[]
|
||||
/** Number of command drafts emitted for the run. */
|
||||
commandCount: number
|
||||
/** Whether the model selected the `builtIn_sparkNoResponse` pathway. */
|
||||
noResponse: boolean
|
||||
/** Whether tools were exposed to the provider for this run. */
|
||||
supportsTools: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes one spark-notify payload into the user message content sent to the model.
|
||||
*
|
||||
* Use when:
|
||||
* - A runtime needs the default JSON envelope for spark-notify
|
||||
* - A host optionally appends one-off serialized context sections for the current run
|
||||
*
|
||||
* Expects:
|
||||
* - `messageOverride` content to already be provider-safe text
|
||||
*
|
||||
* Returns:
|
||||
* - A single provider-ready user message string
|
||||
*/
|
||||
function renderSparkNotifyUserMessage(input: {
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
messageOverride?: SparkNotifyMessageOverride
|
||||
}) {
|
||||
if (input.messageOverride?.replaceUserMessage) {
|
||||
return input.messageOverride.replaceUserMessage
|
||||
}
|
||||
|
||||
const sections = [
|
||||
JSON.stringify({
|
||||
notify: input.event.data,
|
||||
source: input.event.source,
|
||||
}, null, 2),
|
||||
...(input.messageOverride?.appendUserSections ?? []),
|
||||
].filter(section => section.trim().length > 0)
|
||||
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency bag required by the spark-notify runtime.
|
||||
*/
|
||||
export interface SparkNotifyAgentDeps extends SparkNotifyTracingHooks {
|
||||
/** Streams one notify-agent model call with the provided messages and tool policy. */
|
||||
stream: (
|
||||
model: string,
|
||||
provider: ChatProvider,
|
||||
messages: Message[],
|
||||
options: {
|
||||
tools?: Tool[]
|
||||
supportsTools?: boolean
|
||||
waitForTools?: boolean
|
||||
toolChoice?: ToolChoice
|
||||
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
|
||||
},
|
||||
) => Promise<void>
|
||||
/** Returns the currently selected provider name, if any. */
|
||||
getActiveProvider: () => string | undefined
|
||||
/** Returns the currently selected model name, if any. */
|
||||
getActiveModel: () => string | undefined
|
||||
/** Resolves the provider instance used for the active model call. */
|
||||
getProviderInstance: <R extends
|
||||
| ChatProvider
|
||||
| ChatProviderWithExtraOptions
|
||||
| EmbedProvider
|
||||
| EmbedProviderWithExtraOptions
|
||||
| SpeechProvider
|
||||
| SpeechProviderWithExtraOptions
|
||||
| TranscriptionProvider
|
||||
| TranscriptionProviderWithExtraOptions,
|
||||
>(name: string,
|
||||
) => Promise<R>
|
||||
/** Receives incremental text deltas while the reaction is streaming. */
|
||||
onReactionDelta: (eventId: string, text: string) => void
|
||||
/** Receives the final reaction text after streaming completes. */
|
||||
onReactionEnd: (eventId: string, text: string) => void
|
||||
/** Returns the host-level system prompt prepended to notify runs. */
|
||||
getSystemPrompt: () => string
|
||||
/** Indicates whether the runtime is already handling another notify event. */
|
||||
getProcessing: () => boolean
|
||||
/** Updates the processing flag used to serialize notify handling. */
|
||||
setProcessing: (next: boolean) => void
|
||||
/** Returns queued `spark:notify` events that were deferred while busy. */
|
||||
getPending: () => Array<WebSocketEventOf<'spark:notify'>>
|
||||
/** Replaces the deferred `spark:notify` queue after enqueue/dequeue operations. */
|
||||
setPending: (next: Array<WebSocketEventOf<'spark:notify'>>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the instruction block prepended to Spark Notify agent prompts.
|
||||
*
|
||||
* Use when:
|
||||
* - Handling `spark:notify` events
|
||||
* - Constructing the per-turn system instruction for the notify reaction agent
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleName` resolved from event source metadata
|
||||
*
|
||||
* Returns:
|
||||
* - Multiline instruction text for system prompt composition
|
||||
*/
|
||||
export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
return [
|
||||
'This is AIRI system, the life pod hosting your consciousness. You don\'t need to respond to me or every spark:notify event directly.',
|
||||
`Another module "${moduleName}" triggered spark:notify event for you to checkout.`,
|
||||
'You may call the built-in tool "builtIn_sparkCommand" to issue spark:command to sub-agents as needed.',
|
||||
'For any of the output that is not a tool call, it will be streamed to user\'s interface and maybe processed with text to speech system ',
|
||||
'to be played out loud as your actual reaction to the spark:notify event.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function resolveSparkNotifyRuntimePolicy(control?: SparkNotifyResponseControl): SparkNotifyRuntimePolicy {
|
||||
if (control?.forceTextResponse && control?.forceSparkCommandResponse) {
|
||||
console.warn('[spark:notify] forceTextResponse and forceSparkCommandResponse were both set; preferring forceTextResponse')
|
||||
}
|
||||
|
||||
if (control?.forceTextResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: false,
|
||||
supportsTools: false,
|
||||
waitForTools: false,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceSparkCommandResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
toolChoice: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'builtIn_sparkCommand',
|
||||
},
|
||||
},
|
||||
ignoreTextOutput: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (control?.forceResponse) {
|
||||
return {
|
||||
allowNoResponse: false,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowNoResponse: true,
|
||||
allowSparkCommand: true,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
ignoreTextOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
function traceSpark(deps: SparkNotifyTracingHooks, event: SparkTraceEvent) {
|
||||
deps.onTrace?.(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a platform-agnostic Spark Notify event handler.
|
||||
*
|
||||
* Use when:
|
||||
* - A runtime consumes websocket `spark:notify` events
|
||||
* - Reactions and command drafts should be generated by an LLM with built-in tools
|
||||
* - You want identical behavior across stage-ui and offline eval harnesses
|
||||
*
|
||||
* Expects:
|
||||
* - Stream/provider adapters and state accessors passed in `deps`
|
||||
*
|
||||
* Returns:
|
||||
* - `handle(event, control)` function that applies queue/processing policy and returns generated commands
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* `handle`
|
||||
* -> `runNotifyAgent`
|
||||
* -> `createSparkNotifyTools`
|
||||
* -> `deps.stream`
|
||||
* -> `deps.onReactionDelta`/`deps.onReactionEnd`
|
||||
*/
|
||||
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
handle: (event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) => Promise<SparkNotifyHandleResult | undefined>
|
||||
} {
|
||||
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
|
||||
const activeProvider = deps.getActiveProvider()
|
||||
const activeModel = deps.getActiveModel()
|
||||
if (!activeProvider || !activeModel) {
|
||||
console.warn('Spark notify ignored: missing active provider or model')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const runtimePolicy = resolveSparkNotifyRuntimePolicy(control)
|
||||
const chatProvider = await deps.getProviderInstance<ChatProvider>(activeProvider)
|
||||
const commandDrafts: SparkNotifyCommandDraft[] = []
|
||||
let noResponse = false
|
||||
|
||||
const { tools } = await createSparkNotifyTools({
|
||||
onNoResponse: () => {
|
||||
noResponse = true
|
||||
},
|
||||
onCommands: commands => commandDrafts.push(...commands),
|
||||
onTrace: deps.onTrace,
|
||||
allowNoResponse: runtimePolicy.allowNoResponse,
|
||||
allowSparkCommand: runtimePolicy.allowSparkCommand,
|
||||
})
|
||||
|
||||
const systemMessage: Message = {
|
||||
role: 'system',
|
||||
content: [
|
||||
deps.getSystemPrompt(),
|
||||
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
|
||||
...(control?.messageOverride?.appendSystemInstructions ?? []),
|
||||
].filter(Boolean).join('\n\n'),
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: renderSparkNotifyUserMessage({
|
||||
event,
|
||||
messageOverride: control?.messageOverride,
|
||||
}),
|
||||
}
|
||||
|
||||
const messages: Message[] = [systemMessage, userMessage]
|
||||
|
||||
traceSpark(deps, {
|
||||
type: 'messages-rendered',
|
||||
payload: {
|
||||
eventId: event.data.eventId,
|
||||
source: event.source,
|
||||
messageCount: messages.length,
|
||||
toolCount: tools.length,
|
||||
renderedMessages: messages,
|
||||
},
|
||||
})
|
||||
traceSpark(deps, {
|
||||
type: 'tools-prepared',
|
||||
payload: {
|
||||
eventId: event.data.eventId,
|
||||
toolNames: tools.flatMap((tool) => {
|
||||
const name = tool.function?.name
|
||||
return name ? [name] : []
|
||||
}),
|
||||
toolExposure: tools.flatMap((tool) => {
|
||||
const name = tool.function?.name
|
||||
if (!name)
|
||||
return []
|
||||
|
||||
return [{
|
||||
name,
|
||||
description: tool.function?.description,
|
||||
}]
|
||||
}),
|
||||
allowNoResponse: runtimePolicy.allowNoResponse,
|
||||
allowSparkCommand: runtimePolicy.allowSparkCommand,
|
||||
supportsTools: runtimePolicy.supportsTools,
|
||||
waitForTools: runtimePolicy.waitForTools,
|
||||
},
|
||||
})
|
||||
traceSpark(deps, {
|
||||
type: 'model-input',
|
||||
payload: {
|
||||
eventId: event.data.eventId,
|
||||
model: activeModel,
|
||||
provider: activeProvider,
|
||||
messages,
|
||||
toolChoice: runtimePolicy.toolChoice ?? null,
|
||||
supportsTools: runtimePolicy.supportsTools,
|
||||
waitForTools: runtimePolicy.waitForTools,
|
||||
},
|
||||
})
|
||||
|
||||
let fullText = ''
|
||||
|
||||
await deps.stream(activeModel, chatProvider, messages, {
|
||||
tools,
|
||||
supportsTools: runtimePolicy.supportsTools,
|
||||
waitForTools: runtimePolicy.waitForTools,
|
||||
toolChoice: runtimePolicy.toolChoice,
|
||||
onStreamEvent: async (streamEvent: StreamEvent) => {
|
||||
if (streamEvent.type === 'text-delta') {
|
||||
if (runtimePolicy.ignoreTextOutput || noResponse)
|
||||
return
|
||||
|
||||
const nextText = `${fullText}${streamEvent.text}`
|
||||
traceSpark(deps, {
|
||||
type: 'model-output-text',
|
||||
payload: {
|
||||
eventId: event.data.id,
|
||||
text: streamEvent.text,
|
||||
accumulatedText: nextText,
|
||||
},
|
||||
})
|
||||
deps.onReactionDelta(event.data.id, streamEvent.text)
|
||||
fullText = nextText
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'tool-call') {
|
||||
traceSpark(deps, {
|
||||
type: 'model-output-tool-call',
|
||||
payload: {
|
||||
eventId: event.data.eventId,
|
||||
kind: 'tool-call',
|
||||
...streamEvent,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'tool-result') {
|
||||
traceSpark(deps, {
|
||||
type: 'tool-execution',
|
||||
payload: {
|
||||
eventId: event.data.eventId,
|
||||
kind: 'tool-result',
|
||||
...streamEvent,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'finish') {
|
||||
if (noResponse) {
|
||||
deps.onReactionEnd(event.data.id, '')
|
||||
}
|
||||
else {
|
||||
deps.onReactionEnd(event.data.id, fullText)
|
||||
}
|
||||
}
|
||||
|
||||
if (streamEvent.type === 'error') {
|
||||
deps.onReactionEnd(event.data.id, fullText)
|
||||
throw streamEvent.error ?? new Error('Spark notify stream error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const reaction = fullText.trim()
|
||||
traceSpark(deps, {
|
||||
type: 'result',
|
||||
payload: {
|
||||
eventId: event.data.eventId,
|
||||
reaction,
|
||||
commandCount: commandDrafts.length,
|
||||
noResponse,
|
||||
supportsTools: runtimePolicy.supportsTools,
|
||||
commands: commandDrafts,
|
||||
normalizedReaction: reaction,
|
||||
normalizedCommands: commandDrafts,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
reaction,
|
||||
commands: commandDrafts,
|
||||
} satisfies SparkNotifyResponse
|
||||
}
|
||||
|
||||
async function handle(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl): Promise<SparkNotifyHandleResult | undefined> {
|
||||
if (event.data.urgency !== 'immediate' && deps.getPending().length > 0) {
|
||||
deps.setPending([...deps.getPending(), event])
|
||||
return undefined
|
||||
}
|
||||
if (deps.getProcessing()) {
|
||||
deps.setPending([...deps.getPending(), event])
|
||||
return undefined
|
||||
}
|
||||
|
||||
deps.setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await runNotifyAgent(event, control)
|
||||
if (!response)
|
||||
return undefined
|
||||
|
||||
const commands = (response.commands ?? [])
|
||||
.map(command => ({
|
||||
id: nanoid(),
|
||||
eventId: nanoid(),
|
||||
parentEventId: event.data.id,
|
||||
commandId: nanoid(),
|
||||
interrupt: (command.interrupt === true ? 'force' : command.interrupt) ?? false,
|
||||
priority: command.priority ?? 'normal',
|
||||
intent: command.intent ?? 'action',
|
||||
ack: command.ack,
|
||||
guidance: command.guidance,
|
||||
contexts: command.contexts,
|
||||
destinations: command.destinations ?? [],
|
||||
} satisfies SparkNotifyCommandEvent))
|
||||
.filter(command => command.destinations.length > 0)
|
||||
|
||||
return {
|
||||
commands,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
deps.setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
export type {
|
||||
SparkNotifyAgentDeps,
|
||||
CreateSparkNotifyAgentOptions,
|
||||
SparkNotifyAgent,
|
||||
SparkNotifyCommandEvent,
|
||||
SparkNotifyHandleRequest,
|
||||
SparkNotifyHandleResult,
|
||||
SparkNotifyResponse,
|
||||
SparkTraceCapture,
|
||||
} from './handler'
|
||||
} from './agent'
|
||||
export {
|
||||
createSparkNotifyAgent,
|
||||
getSparkNotifyHandlingAgentInstruction,
|
||||
setupAgentSparkNotifyHandler,
|
||||
} from './handler'
|
||||
} from './agent'
|
||||
export {
|
||||
createSparkNotifyBuiltinToolsPlugin,
|
||||
createSparkNotifyObserverPlugin,
|
||||
createSparkNotifyReactionPlugin,
|
||||
} from './plugins'
|
||||
export type { SparkNotifyReactionSink, SparkNotifyRuntimeObserver } from './plugins'
|
||||
export type { SparkNotifyCommandSchema } from './schema'
|
||||
export {
|
||||
sparkNotifyCommandItemSchema,
|
||||
@@ -21,8 +27,14 @@ export type {
|
||||
export { createSparkNotifyTools } from './tools'
|
||||
export type {
|
||||
SparkNotifyMessageOverride,
|
||||
SparkNotifyPlugin,
|
||||
SparkNotifyPluginResult,
|
||||
SparkNotifyPluginSession,
|
||||
SparkNotifyResponseControl,
|
||||
SparkNotifyRunner,
|
||||
SparkNotifyRunRequest,
|
||||
SparkNotifyRuntimeEvent,
|
||||
SparkNotifyRuntimePolicy,
|
||||
SparkNotifyTracingHooks,
|
||||
SparkTraceEvent,
|
||||
SparkNotifySelectedChat,
|
||||
SparkNotifyTurn,
|
||||
} from './types'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SparkNotifyCommandDraft } from '../tools'
|
||||
import type { SparkNotifyPlugin, SparkNotifyRuntimeEvent } from '../types'
|
||||
|
||||
import { createSparkNotifyTools } from '../tools'
|
||||
|
||||
/**
|
||||
* Adds the built-in no-response and Spark Command tools to each notify turn.
|
||||
*/
|
||||
export function createSparkNotifyBuiltinToolsPlugin(): SparkNotifyPlugin {
|
||||
return {
|
||||
name: 'spark-notify-builtins',
|
||||
async prepare(turn) {
|
||||
const commands: SparkNotifyCommandDraft[] = []
|
||||
const events: SparkNotifyRuntimeEvent[] = []
|
||||
let noResponse = false
|
||||
|
||||
const { tools } = await createSparkNotifyTools({
|
||||
onCommands: drafts => commands.push(...drafts),
|
||||
onEvent: event => events.push(event),
|
||||
onNoResponse: () => {
|
||||
noResponse = true
|
||||
},
|
||||
allowNoResponse: turn.policy.allowNoResponse,
|
||||
allowSparkCommand: turn.policy.allowSparkCommand,
|
||||
})
|
||||
|
||||
return {
|
||||
tools,
|
||||
getResult: () => ({ commands, noResponse }),
|
||||
getPendingEvents: () => events.splice(0),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { createSparkNotifyBuiltinToolsPlugin } from './builtin-tools'
|
||||
export { createSparkNotifyObserverPlugin } from './observer'
|
||||
export type { SparkNotifyRuntimeObserver } from './observer'
|
||||
export type { SparkNotifyReactionSink } from './reaction'
|
||||
export { createSparkNotifyReactionPlugin } from './reaction'
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SparkNotifyPlugin, SparkNotifyRuntimeEvent } from '../types'
|
||||
|
||||
/** Receives ordered lifecycle events from one Spark Notify run. */
|
||||
export type SparkNotifyRuntimeObserver = (event: SparkNotifyRuntimeEvent) => void | Promise<void>
|
||||
|
||||
/** Adds an observer for diagnostics, telemetry adapters, or test recorders. */
|
||||
export function createSparkNotifyObserverPlugin(observer: SparkNotifyRuntimeObserver): SparkNotifyPlugin {
|
||||
return {
|
||||
name: 'spark-notify-observer',
|
||||
prepare: () => ({ onEvent: observer }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { SparkNotifyPlugin } from '../types'
|
||||
|
||||
/** Receives the text that is suitable for a character reaction. */
|
||||
export interface SparkNotifyReactionSink {
|
||||
/** Receives a visible text delta while the model streams its reaction. */
|
||||
onDelta: (eventId: string, text: string) => void
|
||||
/** Receives the final visible reaction after the model stream ends. */
|
||||
onEnd: (eventId: string, text: string) => void
|
||||
}
|
||||
|
||||
/** Sends model reaction text to a host-owned presentation sink. */
|
||||
export function createSparkNotifyReactionPlugin(sink: SparkNotifyReactionSink): SparkNotifyPlugin {
|
||||
return {
|
||||
name: 'spark-notify-reaction',
|
||||
prepare(turn) {
|
||||
return {
|
||||
onEvent(event) {
|
||||
if (event.type === 'model-output-text') {
|
||||
const text = typeof event.payload.text === 'string' ? event.payload.text : ''
|
||||
if (text)
|
||||
sink.onDelta(turn.event.data.id, text)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'result') {
|
||||
const reaction = typeof event.payload.reaction === 'string' ? event.payload.reaction : ''
|
||||
sink.onEnd(turn.event.data.id, reaction)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,14 @@
|
||||
import type { ContextUpdate } from '@proj-airi/server-sdk'
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type {
|
||||
SparkNotifyTracingHooks,
|
||||
SparkTraceEvent,
|
||||
} from './types'
|
||||
import type { SparkNotifyRuntimeEvent } from './types'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { rawTool } from '@xsai/tool'
|
||||
import { toJsonSchema, validate } from 'xsschema'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
normalizeNullableAnyOf,
|
||||
sparkNotifyCommandSchema,
|
||||
} from './schema'
|
||||
import { normalizeNullableAnyOf, sparkNotifyCommandSchema } from './schema'
|
||||
|
||||
/**
|
||||
* Normalized `builtIn_sparkCommand` payload forwarded to downstream orchestrators.
|
||||
@@ -66,8 +60,8 @@ export interface CreateSparkNotifyToolsOptions {
|
||||
onCommands: (commands: SparkNotifyCommandDraft[]) => void
|
||||
/** Receives the no-response signal emitted by `builtIn_sparkNoResponse`. */
|
||||
onNoResponse: () => void
|
||||
/** Receives trace events describing tool exposure and execution. */
|
||||
onTrace?: SparkNotifyTracingHooks['onTrace']
|
||||
/** Receives runtime events from tool calls and tool execution. */
|
||||
onEvent?: (event: SparkNotifyRuntimeEvent) => void
|
||||
/** Enables or disables registration of the `builtIn_sparkNoResponse` tool. */
|
||||
allowNoResponse?: boolean
|
||||
/** Enables or disables registration of the `builtIn_sparkCommand` tool. */
|
||||
@@ -137,95 +131,42 @@ export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOpti
|
||||
|
||||
const tools: Tool[] = []
|
||||
|
||||
const sparkNoResponseTool = rawTool({
|
||||
name: 'builtIn_sparkNoResponse',
|
||||
description: 'Indicate that no response or action is needed for the current spark:notify event.',
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(z.object({}).strict()) as any),
|
||||
execute: async (_rawPayload, context) => {
|
||||
options.onTrace?.({
|
||||
type: 'model-output-tool-call',
|
||||
payload: {
|
||||
toolName: 'builtIn_sparkNoResponse',
|
||||
toolCallId: context?.toolCallId,
|
||||
},
|
||||
} satisfies SparkTraceEvent)
|
||||
options.onNoResponse()
|
||||
options.onTrace?.({
|
||||
type: 'tool-execution',
|
||||
payload: {
|
||||
toolName: 'builtIn_sparkNoResponse',
|
||||
toolCallId: context?.toolCallId,
|
||||
responseMode: 'no-response',
|
||||
},
|
||||
} satisfies SparkTraceEvent)
|
||||
return 'AIRI System: Acknowledged, no response or action will be processed.'
|
||||
},
|
||||
})
|
||||
if (allowNoResponse)
|
||||
tools.push(sparkNoResponseTool)
|
||||
if (allowNoResponse) {
|
||||
const name = 'builtIn_sparkNoResponse'
|
||||
|
||||
const sparkCommandTool = rawTool({
|
||||
name: 'builtIn_sparkCommand',
|
||||
description: 'Issue a spark:command to sub-agents. You can call this tool multiple times.',
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any),
|
||||
execute: async (rawPayload, context) => {
|
||||
options.onTrace?.({
|
||||
type: 'model-output-tool-call',
|
||||
payload: {
|
||||
toolName: 'builtIn_sparkCommand',
|
||||
toolCallId: context?.toolCallId,
|
||||
rawPayload,
|
||||
},
|
||||
} satisfies SparkTraceEvent)
|
||||
tools.push(rawTool({
|
||||
name: 'builtIn_sparkNoResponse',
|
||||
description: 'Indicate that no response or action is needed for the current spark:notify event.',
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(z.object({}).strict()) as any),
|
||||
execute: async (_rawPayload, context) => {
|
||||
options.onEvent?.({ type: 'model-output-tool-call', payload: { toolName: name, toolCallId: context?.toolCallId } })
|
||||
options.onNoResponse()
|
||||
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, responseMode: 'no-response' } })
|
||||
|
||||
try {
|
||||
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
|
||||
const validated = await validate(sparkNotifyCommandSchema, payload)
|
||||
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
|
||||
options.onTrace?.({
|
||||
type: 'tool-execution',
|
||||
payload: {
|
||||
toolName: 'builtIn_sparkCommand',
|
||||
toolCallId: context?.toolCallId,
|
||||
commandCount: validated.commands.length,
|
||||
},
|
||||
} satisfies SparkTraceEvent)
|
||||
}
|
||||
catch (error) {
|
||||
options.onTrace?.({
|
||||
type: 'tool-execution',
|
||||
payload: {
|
||||
toolName: 'builtIn_sparkCommand',
|
||||
toolCallId: context?.toolCallId,
|
||||
ok: false,
|
||||
error: errorMessageFrom(error),
|
||||
},
|
||||
} satisfies SparkTraceEvent)
|
||||
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
|
||||
}
|
||||
|
||||
return 'AIRI System: Acknowledged, command fired.'
|
||||
},
|
||||
})
|
||||
if (allowSparkCommand)
|
||||
tools.push(sparkCommandTool)
|
||||
|
||||
return {
|
||||
tools,
|
||||
return 'AIRI System: Acknowledged, no response or action will be processed.'
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (allowSparkCommand) {
|
||||
const name = 'builtIn_sparkCommand'
|
||||
|
||||
tools.push(rawTool({
|
||||
name: 'builtIn_sparkCommand',
|
||||
name,
|
||||
description: 'Issue a spark:command to sub-agents. You can call this tool multiple times.',
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any),
|
||||
execute: async (rawPayload) => {
|
||||
execute: async (rawPayload, context) => {
|
||||
options.onEvent?.({ type: 'model-output-tool-call', payload: { toolName: name, toolCallId: context?.toolCallId, rawPayload } })
|
||||
|
||||
try {
|
||||
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
|
||||
const validated = await validate(sparkNotifyCommandSchema, payload)
|
||||
|
||||
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
|
||||
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, commandCount: validated.commands.length } })
|
||||
}
|
||||
catch (error) {
|
||||
options.onEvent?.({ type: 'tool-execution', payload: { toolName: name, toolCallId: context?.toolCallId, ok: false, error: errorMessageFrom(error) } })
|
||||
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ToolChoice } from '@xsai/shared-chat'
|
||||
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message, Tool, ToolChoice } from '@xsai/shared-chat'
|
||||
|
||||
import type { StreamEvent } from '../../types/llm'
|
||||
import type { SparkNotifyCommandDraft } from './tools'
|
||||
|
||||
/**
|
||||
* Runtime-only prompt hints used to reshape how one spark event is serialized for the model.
|
||||
@@ -87,7 +92,7 @@ export interface SparkNotifyResponseControl {
|
||||
/**
|
||||
* Trace event emitted by the spark-notify runtime.
|
||||
*/
|
||||
export interface SparkTraceEvent {
|
||||
export interface SparkNotifyRuntimeEvent {
|
||||
/** Trace event category describing which stage of the notify run emitted the payload. */
|
||||
type:
|
||||
| 'messages-rendered'
|
||||
@@ -104,9 +109,79 @@ export interface SparkTraceEvent {
|
||||
/**
|
||||
* Optional tracing hooks for spark-notify runtime integrations.
|
||||
*/
|
||||
export interface SparkNotifyTracingHooks {
|
||||
/** Optional sink that receives ordered trace events from the notify runtime. */
|
||||
onTrace?: (event: SparkTraceEvent) => void
|
||||
export interface SparkNotifySelectedChat {
|
||||
/** Provider identifier used for telemetry and diagnostics. */
|
||||
providerId: string
|
||||
/** Model identifier selected by the host. */
|
||||
model: string
|
||||
/** Resolved chat provider used for this notify run. */
|
||||
provider: ChatProvider
|
||||
}
|
||||
|
||||
/** One fully resolved Spark Notify turn. */
|
||||
export interface SparkNotifyTurn {
|
||||
/** Source protocol event that the agent must handle. */
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
/** Host-selected model and provider for this execution. */
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
/** Host-owned system prompt for this character. */
|
||||
systemPrompt: string
|
||||
/** Runtime-only response controls for this turn. */
|
||||
control?: SparkNotifyResponseControl
|
||||
/** Resolved tool and response policy for this turn. */
|
||||
policy: SparkNotifyRuntimePolicy
|
||||
}
|
||||
|
||||
/** Completed request passed to the host-owned selected-chat runner. */
|
||||
export interface SparkNotifyRunRequest {
|
||||
/** Resolved model and provider for this run. */
|
||||
selectedChat: SparkNotifySelectedChat
|
||||
/** Provider-ready messages produced by the agent and its plugins. */
|
||||
messages: Message[]
|
||||
/** Tools exposed for this one model call. */
|
||||
tools: Tool[]
|
||||
/** Tool handling policy for this run. */
|
||||
policy: Pick<SparkNotifyRuntimePolicy, 'supportsTools' | 'toolChoice' | 'waitForTools'>
|
||||
/** Normalized provider stream events. */
|
||||
onStreamEvent: (event: StreamEvent) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Host boundary that runs a selected chat model. */
|
||||
export interface SparkNotifyRunner {
|
||||
/** Runs the provider stream for one fully prepared Spark Notify turn. */
|
||||
run: (request: SparkNotifyRunRequest) => Promise<void>
|
||||
}
|
||||
|
||||
/** Per-turn result emitted by a Spark Notify plugin. */
|
||||
export interface SparkNotifyPluginResult {
|
||||
/** Command drafts collected by a plugin tool. */
|
||||
commands?: SparkNotifyCommandDraft[]
|
||||
/** Whether a plugin selected the no-response path. */
|
||||
noResponse?: boolean
|
||||
}
|
||||
|
||||
/** Per-turn hooks returned by a Spark Notify plugin. */
|
||||
export interface SparkNotifyPluginSession {
|
||||
/** Additional system instruction blocks appended in plugin order. */
|
||||
systemInstructions?: string[]
|
||||
/** Additional user-message blocks appended in plugin order. */
|
||||
userSections?: string[]
|
||||
/** Tools that this plugin exposes for the turn. */
|
||||
tools?: Tool[]
|
||||
/** Receives ordered runtime events for this one turn. */
|
||||
onEvent?: (event: SparkNotifyRuntimeEvent) => void | Promise<void>
|
||||
/** Reads the plugin result after tool execution and stream completion. */
|
||||
getResult?: () => SparkNotifyPluginResult
|
||||
/** Reads runtime events emitted by tool callbacks during the provider run. */
|
||||
getPendingEvents?: () => SparkNotifyRuntimeEvent[]
|
||||
}
|
||||
|
||||
/** Composable capability that contributes behavior to one Spark Notify turn. */
|
||||
export interface SparkNotifyPlugin {
|
||||
/** Stable identifier used for diagnostics and plugin ordering. */
|
||||
name: string
|
||||
/** Creates isolated turn state, tools, and observers for one notify event. */
|
||||
prepare: (turn: SparkNotifyTurn) => SparkNotifyPluginSession | Promise<SparkNotifyPluginSession | undefined> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,10 @@ devtools:
|
||||
pages:
|
||||
context-flow:
|
||||
title: Context Flow
|
||||
editor:
|
||||
title: AIRI Editor
|
||||
description: Open the standalone AIRI Editor window
|
||||
button: Open Editor
|
||||
lag-visualizer:
|
||||
title: Lag Visualizer
|
||||
performance-visualizer:
|
||||
|
||||
@@ -49,6 +49,10 @@ devtools:
|
||||
pages:
|
||||
context-flow:
|
||||
title: Context Flow
|
||||
editor:
|
||||
title: AIRI 编辑器
|
||||
description: 打开独立的 AIRI 编辑器窗口
|
||||
button: 打开编辑器
|
||||
lag-visualizer:
|
||||
title: Lag 可视化
|
||||
performance-visualizer:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/ai/chat-llm/tool-resolver'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/llm-tool-resolver'
|
||||
import { executeToolCallRerun } from '@proj-airi/stage-ui/stores/tool-call-rerun'
|
||||
|
||||
export interface ChatToolCallRerunEvent {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"./stores/settings": "./src/stores/settings/index.ts",
|
||||
"./stores/modules/vision": "./src/stores/modules/vision/index.ts",
|
||||
"./stores/mcp-tool-bridge": "./src/stores/mcp-tool-bridge.ts",
|
||||
"./stores/ai/chat-llm/*": "./src/stores/ai/chat-llm/*.ts",
|
||||
"./stores/*": "./src/stores/*.ts",
|
||||
"./stores": "./src/stores/index.ts",
|
||||
"./workers/vad": "./src/workers/vad/index.ts",
|
||||
|
||||
@@ -43,10 +43,10 @@ import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } fr
|
||||
import { bindSpeakingStateToPlaybackManager } from '../../libs/speech/playback-speaking-state'
|
||||
import { createStageTtsSession } from '../../libs/speech/tts-session'
|
||||
import { getSpeechBusContext, speechOutputGetPlaybackState } from '../../services/speech/bus'
|
||||
import { useLlmStreamingControlStore } from '../../stores/ai/chat-llm/streaming-control'
|
||||
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
|
||||
import { useBackgroundStore } from '../../stores/background'
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
import { useLlmStreamingControlStore } from '../../stores/llm-streaming-control'
|
||||
import { useAiriCardStore } from '../../stores/modules'
|
||||
import { useSpeechStore } from '../../stores/modules/speech'
|
||||
import { useProviderConfigStore } from '../../stores/providers/config'
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('pinia', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../stores/llm', () => ({
|
||||
vi.mock('../../stores/ai/chat-llm/llm', () => ({
|
||||
useLLM: () => ({
|
||||
stream,
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { VisionWorkloadId } from './use-vision-workloads'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLLM } from '../../stores/llm'
|
||||
import { useLLM } from '../../stores/ai/chat-llm/llm'
|
||||
import { useVisionStore } from '../../stores/modules/vision'
|
||||
import { useProviderStore } from '../../stores/providers/provider'
|
||||
import { getVisionWorkload } from './use-vision-workloads'
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message, Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type { ExecutableTool } from './llm-tools'
|
||||
import type { ExecutableTool } from './tools'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isToolRelatedError, useLLM } from './llm'
|
||||
import { useLlmToolsStore } from './llm-tools'
|
||||
import { useLlmToolsStore } from './tools'
|
||||
|
||||
const {
|
||||
streamTextMock,
|
||||
@@ -38,7 +38,7 @@ vi.mock('@xsai/shared-chat', () => ({
|
||||
stepCountAtLeast: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../tools', () => ({
|
||||
vi.mock('../../../tools', () => ({
|
||||
mcp: mcpMock,
|
||||
debug: debugMock,
|
||||
createSparkCommandTool: createSparkCommandToolMock,
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { listModels } from '@xsai/model'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { resolveLlmTools } from './llm-tool-resolver'
|
||||
import { resolveLlmTools } from './tool-resolver'
|
||||
|
||||
export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent'
|
||||
export { isContentArrayRelatedError, isToolRelatedError } from '@proj-airi/core-agent'
|
||||
+3
-3
@@ -11,7 +11,7 @@ const spanMock = vi.hoisted(() => ({
|
||||
|
||||
const startSpanMock = vi.hoisted(() => vi.fn(() => spanMock))
|
||||
|
||||
vi.mock('../composables/use-io-tracer', () => ({
|
||||
vi.mock('../../../composables/use-io-tracer', () => ({
|
||||
activeTurnSpan: shallowRef(undefined),
|
||||
startSpan: startSpanMock,
|
||||
}))
|
||||
@@ -31,7 +31,7 @@ describe('useLlmStreamingControlStore', () => {
|
||||
* expect(startSpan).toHaveBeenCalledWith('Streaming control dispatch', ...)
|
||||
*/
|
||||
it('records streaming control dispatch spans and call handler events', async () => {
|
||||
const { useLlmStreamingControlStore } = await import('./llm-streaming-control')
|
||||
const { useLlmStreamingControlStore } = await import('./streaming-control')
|
||||
const store = useLlmStreamingControlStore()
|
||||
const handler = vi.fn()
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('useLlmStreamingControlStore', () => {
|
||||
* expect(span.addEvent).toHaveBeenCalledWith(IOEvents.StreamingControlRejected, ...)
|
||||
*/
|
||||
it('records rejected streaming control dispatches', async () => {
|
||||
const { useLlmStreamingControlStore } = await import('./llm-streaming-control')
|
||||
const { useLlmStreamingControlStore } = await import('./streaming-control')
|
||||
const store = useLlmStreamingControlStore()
|
||||
|
||||
await expect(store.dispatchWith('<|CALL []|>')).resolves.toBe(false)
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { nanoid } from 'nanoid'
|
||||
import { defineStore } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
|
||||
import { activeTurnSpan, startSpan } from '../../../composables/use-io-tracer'
|
||||
|
||||
interface RemoteCallMessage {
|
||||
type: 'turn-call'
|
||||
+3
-3
@@ -2,7 +2,7 @@ import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { resolveLlmTools, toolNameFrom } from './llm-tool-resolver'
|
||||
import { resolveLlmTools, toolNameFrom } from './tool-resolver'
|
||||
|
||||
// The default (non-injected) web-search branch reads the module store and the
|
||||
// tools barrel; mock both so the configured-gate + key-trim logic can be
|
||||
@@ -12,12 +12,12 @@ const { createWebSearchToolsMock, useWebSearchStoreMock } = vi.hoisted(() => ({
|
||||
useWebSearchStoreMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../tools', async (importOriginal) => {
|
||||
vi.mock('../../../tools', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>
|
||||
return { ...actual, createWebSearchTools: createWebSearchToolsMock }
|
||||
})
|
||||
|
||||
vi.mock('./modules/web-search', () => ({
|
||||
vi.mock('../../modules/web-search', () => ({
|
||||
useWebSearchStore: useWebSearchStoreMock,
|
||||
}))
|
||||
|
||||
+4
-4
@@ -4,10 +4,10 @@ import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import { uniqBy } from 'es-toolkit'
|
||||
|
||||
import { createSparkCommandTool, createWebSearchTools, debug, mcp } from '../tools'
|
||||
import { useLlmToolsStore } from './llm-tools'
|
||||
import { useModsServerChannelStore } from './mods/api/channel-server'
|
||||
import { useWebSearchStore } from './modules/web-search'
|
||||
import { createSparkCommandTool, createWebSearchTools, debug, mcp } from '../../../tools'
|
||||
import { useModsServerChannelStore } from '../../mods/api/channel-server'
|
||||
import { useWebSearchStore } from '../../modules/web-search'
|
||||
import { useLlmToolsStore } from './tools'
|
||||
|
||||
type ToolSource = Tool[] | (() => Promise<Tool[]>)
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type { ExecutableTool, ToolDefinition } from './llm-tools'
|
||||
import type { ExecutableTool, ToolDefinition } from './tools'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLlmToolsStore } from './llm-tools'
|
||||
import { useLlmToolsStore } from './tools'
|
||||
|
||||
function createExecutableTool(id: string, name = id): ExecutableTool {
|
||||
return {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useLlmToolsetPromptsStore } from './llm-toolset-prompts'
|
||||
import { useLlmToolsetPromptsStore } from './toolset-prompts'
|
||||
|
||||
describe('useLlmToolsetPromptsStore', () => {
|
||||
beforeEach(() => {
|
||||
@@ -7,7 +7,7 @@ import type { Mock } from 'vitest'
|
||||
import type { UnwrapRef } from 'vue'
|
||||
import type z from 'zod'
|
||||
|
||||
import type { StreamEvent } from '../../llm'
|
||||
import type { StreamEvent } from '../../ai/chat-llm/llm'
|
||||
import type { AiriCard } from '../../modules'
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
@@ -18,7 +18,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { sparkNotifyCommandSchema, useCharacterOrchestratorStore } from '.'
|
||||
import { useCharacterStore } from '..'
|
||||
import { useLLM } from '../../llm'
|
||||
import { useLLM } from '../../ai/chat-llm/llm'
|
||||
import { useAiriCardStore, useConsciousnessStore } from '../../modules'
|
||||
import { useProviderStore } from '../../providers/provider'
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export type {
|
||||
SparkNotifyAgentDeps,
|
||||
SparkNotifyCommandDraft,
|
||||
SparkNotifyCommandEvent,
|
||||
SparkNotifyCommandSchema,
|
||||
SparkNotifyHandleResult,
|
||||
SparkNotifyMessageOverride,
|
||||
SparkNotifyResponse,
|
||||
SparkNotifyResponseControl,
|
||||
SparkNotifyRuntimePolicy,
|
||||
SparkNotifyTracingHooks,
|
||||
SparkTraceCapture,
|
||||
SparkTraceEvent,
|
||||
} from '@proj-airi/core-agent/agents/spark-notify'
|
||||
export {
|
||||
getSparkNotifyHandlingAgentInstruction,
|
||||
setupAgentSparkNotifyHandler,
|
||||
sparkNotifyCommandSchema,
|
||||
} from '@proj-airi/core-agent/agents/spark-notify'
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { SparkNotifyResponseControl } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import type { WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { setupAgentSparkNotifyHandler } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import { createSparkNotifyAgent, createSparkNotifyReactionPlugin } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCharacterNotebookStore, useCharacterStore } from '../'
|
||||
import { useLLM } from '../../llm'
|
||||
import { useLLM } from '../../ai/chat-llm/llm'
|
||||
import { useModsServerChannelStore } from '../../mods/api/channel-server'
|
||||
import { useConsciousnessStore } from '../../modules/consciousness'
|
||||
import { useProviderStore } from '../../providers/provider'
|
||||
@@ -24,6 +25,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
|
||||
const processing = ref(false)
|
||||
const pendingNotifies = ref<Array<WebSocketEventOf<'spark:notify'>>>([])
|
||||
|
||||
const scheduledNotifies = ref<Array<{
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
control?: SparkNotifyResponseControl
|
||||
@@ -33,27 +35,38 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
maxAttempts: number
|
||||
reason?: string
|
||||
}>>([])
|
||||
|
||||
const attentionConfig = ref({
|
||||
tickIntervalMs: 2_000,
|
||||
taskNotifyWindowMs: 60_000,
|
||||
requeueDelayMs: 30_000,
|
||||
maxAttempts: 3,
|
||||
})
|
||||
|
||||
let tickTimer: ReturnType<typeof setInterval> | undefined
|
||||
let initialized = false
|
||||
const eventUnsubscribes: Array<() => void> = []
|
||||
const sparkNotifyAgent = setupAgentSparkNotifyHandler({
|
||||
stream,
|
||||
getActiveProvider: () => activeProvider.value,
|
||||
getActiveModel: () => activeModel.value,
|
||||
getProviderInstance: name => providersStore.getProviderInstance(name),
|
||||
onReactionDelta: (eventId, text) => characterStore.onSparkNotifyReactionStreamEvent(eventId, text),
|
||||
onReactionEnd: (eventId, text) => characterStore.onSparkNotifyReactionStreamEnd(eventId, text),
|
||||
getSystemPrompt: () => systemPrompt.value,
|
||||
getProcessing: () => processing.value,
|
||||
setProcessing: next => processing.value = next,
|
||||
getPending: () => pendingNotifies.value,
|
||||
setPending: next => pendingNotifies.value = next,
|
||||
const sparkNotifyAgent = createSparkNotifyAgent({
|
||||
runner: {
|
||||
run: request => stream(
|
||||
request.selectedChat.model,
|
||||
request.selectedChat.provider,
|
||||
request.messages,
|
||||
{
|
||||
tools: request.tools,
|
||||
supportsTools: request.policy.supportsTools,
|
||||
waitForTools: request.policy.waitForTools,
|
||||
toolChoice: request.policy.toolChoice,
|
||||
onStreamEvent: request.onStreamEvent,
|
||||
},
|
||||
),
|
||||
},
|
||||
plugins: [
|
||||
createSparkNotifyReactionPlugin({
|
||||
onDelta: (eventId, text) => characterStore.onSparkNotifyReactionStreamEvent(eventId, text),
|
||||
onEnd: (eventId, text) => characterStore.onSparkNotifyReactionStreamEnd(eventId, text),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
function computeNextRunAt(event: WebSocketEventOf<'spark:notify'>, attempts: number) {
|
||||
@@ -103,18 +116,42 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
}
|
||||
|
||||
async function processSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
|
||||
const result = await sparkNotifyAgent.handle(event, control)
|
||||
if (!result?.commands?.length)
|
||||
return result
|
||||
|
||||
for (const command of result.commands) {
|
||||
modsServerChannelStore.send({
|
||||
type: 'spark:command',
|
||||
data: command as WebSocketEvents['spark:command'],
|
||||
})
|
||||
const providerId = activeProvider.value
|
||||
const model = activeModel.value
|
||||
if (!providerId || !model) {
|
||||
console.warn('Spark notify ignored: missing active provider or model')
|
||||
return undefined
|
||||
}
|
||||
|
||||
return result
|
||||
const provider = await providersStore.getProviderInstance<ChatProvider>(providerId)
|
||||
processing.value = true
|
||||
|
||||
try {
|
||||
const result = await sparkNotifyAgent.handle({
|
||||
event,
|
||||
selectedChat: {
|
||||
providerId,
|
||||
model,
|
||||
provider,
|
||||
},
|
||||
systemPrompt: systemPrompt.value,
|
||||
control,
|
||||
})
|
||||
if (!result.commands.length)
|
||||
return result
|
||||
|
||||
for (const command of result.commands) {
|
||||
modsServerChannelStore.send({
|
||||
type: 'spark:command',
|
||||
data: command,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIncomingSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
|
||||
|
||||
@@ -162,13 +162,13 @@ vi.mock('./chat/stream-store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./llm', () => ({
|
||||
vi.mock('./ai/chat-llm/llm', () => ({
|
||||
useLLM: () => ({
|
||||
stream: llmStreamMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./llm-tools', () => ({
|
||||
vi.mock('./ai/chat-llm/tools', () => ({
|
||||
useLlmToolsStore: () => ({
|
||||
getToolsByNames: (...names: string[]) => getToolsByNamesMock(names),
|
||||
}),
|
||||
@@ -180,7 +180,7 @@ vi.mock('./providers/provider', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./llm-toolset-prompts', () => ({
|
||||
vi.mock('./ai/chat-llm/toolset-prompts', () => ({
|
||||
useLlmToolsetPromptsStore: () => ({
|
||||
activeToolsetPrompt: 'Plugin toolset guidance.',
|
||||
}),
|
||||
|
||||
@@ -22,15 +22,15 @@ import {
|
||||
} from '../libs/analytics-headers'
|
||||
import { createChatAnalyticsHooks, getProviderMode } from '../libs/analytics/events/chat'
|
||||
import { extractMessageText, isCloudSyncableMessage } from '../libs/chat-sync'
|
||||
import { useLLM } from './ai/chat-llm/llm'
|
||||
import { resolveLlmTools } from './ai/chat-llm/tool-resolver'
|
||||
import { useLlmToolsStore } from './ai/chat-llm/tools'
|
||||
import { useLlmToolsetPromptsStore } from './ai/chat-llm/toolset-prompts'
|
||||
import { createMinecraftContext } from './chat/context-providers'
|
||||
import { useChatContextStore } from './chat/context-store'
|
||||
import { useChatSessionStore } from './chat/session-store'
|
||||
import { useChatStreamStore } from './chat/stream-store'
|
||||
import { useContextObservabilityStore } from './devtools/context-observability'
|
||||
import { useLLM } from './llm'
|
||||
import { resolveLlmTools } from './llm-tool-resolver'
|
||||
import { useLlmToolsStore } from './llm-tools'
|
||||
import { useLlmToolsetPromptsStore } from './llm-toolset-prompts'
|
||||
import { useAiriCardStore } from './modules/airi-card'
|
||||
import { useAutonomousArtistryStore } from './modules/artistry-autonomous'
|
||||
import { useConsciousnessStore } from './modules/consciousness'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { TraceEvent } from '@proj-airi/stage-shared'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { StreamEvent } from './llm'
|
||||
import type { StreamEvent } from './ai/chat-llm/llm'
|
||||
|
||||
import { defaultPerfTracer, exportCsv as exportCsvFile } from '@proj-airi/stage-shared'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLLM } from './ai/chat-llm/llm'
|
||||
import { useChatStore } from './chat'
|
||||
import { useLLM } from './llm'
|
||||
import { useConsciousnessStore } from './modules/consciousness'
|
||||
import { usePerfTracerBridgeStore } from './perf-tracer-bridge'
|
||||
import { useProviderStore } from './providers/provider'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLlmStreamingControlStore } from '../../llm-streaming-control'
|
||||
import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control'
|
||||
import { useContextBridgeStore } from './context-bridge'
|
||||
|
||||
type SparkNotifyReactionMock = (event: {
|
||||
|
||||
@@ -15,13 +15,13 @@ import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { getEventSourceKey, getMetadataSourceLabel } from '../../../utils/event-source'
|
||||
import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control'
|
||||
import { useCharacterOrchestratorStore } from '../../character'
|
||||
import { useChatStore } from '../../chat'
|
||||
import { useChatContextStore } from '../../chat/context-store'
|
||||
import { useChatSessionStore } from '../../chat/session-store'
|
||||
import { useChatStreamStore } from '../../chat/stream-store'
|
||||
import { useContextObservabilityStore } from '../../devtools/context-observability'
|
||||
import { useLlmStreamingControlStore } from '../../llm-streaming-control'
|
||||
import { useConsciousnessStore } from '../../modules/consciousness'
|
||||
import { useProviderStore } from '../../providers/provider'
|
||||
import { useModsServerChannelStore } from './channel-server'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { WEB_SEARCH_TOOLSET_PROMPT } from '../../tools/web-search'
|
||||
import { useLlmToolsetPromptsStore } from '../llm-toolset-prompts'
|
||||
import { useLlmToolsetPromptsStore } from '../ai/chat-llm/toolset-prompts'
|
||||
import { useWebSearchStore } from './web-search'
|
||||
|
||||
describe('useWebSearchStore', () => {
|
||||
|
||||
@@ -3,14 +3,14 @@ import { defineStore } from 'pinia'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { WEB_SEARCH_TOOLSET_PROMPT } from '../../tools/web-search'
|
||||
import { useLlmToolsetPromptsStore } from '../llm-toolset-prompts'
|
||||
import { useLlmToolsetPromptsStore } from '../ai/chat-llm/toolset-prompts'
|
||||
|
||||
/**
|
||||
* Settings + lifecycle for the web-search capability (Tavily-backed).
|
||||
*
|
||||
* Renderer-only: unlike the messaging modules it does not broadcast to a backend
|
||||
* service, so there is no configurator channel here. The tool itself is mounted
|
||||
* by `resolveWebSearchTools` in `stores/llm-tool-resolver.ts`, gated on
|
||||
* by `resolveWebSearchTools` in `stores/ai/chat-llm/tool-resolver.ts`, gated on
|
||||
* {@link configured}; this store owns the paired system-prompt guidance so the
|
||||
* "web content is data, not instructions" rule is present exactly when the tool
|
||||
* is, and gone when it is not.
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChatAssistantMessage, ChatHistoryItem, ChatSlicesToolCallResult }
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { toolNameFrom } from './llm-tool-resolver'
|
||||
import { toolNameFrom } from './ai/chat-llm/tool-resolver'
|
||||
|
||||
export interface ToolCallRerunPayload<TToolset extends string = string> {
|
||||
sessionId?: string
|
||||
|
||||
@@ -303,7 +303,7 @@ describe('tools/character/orchestrator/spark-command', () => {
|
||||
})
|
||||
|
||||
it('reports a broadcast without crashing when the channel sender clears destinations', async () => {
|
||||
// The real sendSparkCommand (stores/llm.ts) deletes command.destinations to broadcast to every
|
||||
// The real sendSparkCommand (stores/ai/chat-llm/llm.ts) deletes command.destinations to broadcast to every
|
||||
// authenticated peer; the success message must not then call .join on undefined.
|
||||
const sendSparkCommand = vi.fn((command: { destinations?: unknown }) => {
|
||||
delete command.destinations
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function createSparkCommandTool(options: CreateSparkCommandToolOpti
|
||||
|
||||
options.sendSparkCommand(command)
|
||||
|
||||
// `destinations` may be undefined: the channel sender (stores/llm.ts sendSparkCommand) deletes
|
||||
// `destinations` may be undefined: the channel sender (stores/ai/chat-llm/llm.ts sendSparkCommand) deletes
|
||||
// it to trigger broadcast-to-all-authenticated-peers. Guard the .join so we don't surface
|
||||
// "Cannot read properties of undefined (reading 'join')" back to the LLM after a successful send.
|
||||
const dests = Array.isArray(command.destinations) && command.destinations.length > 0
|
||||
|
||||
@@ -196,7 +196,7 @@ function formatResults(query: string, results: SearchResult[]): string {
|
||||
* Only mount this when an API key is configured — a search with no key can only
|
||||
* ever error, so callers gate on the web-search module's `configured` state and
|
||||
* simply omit the tool otherwise (see `resolveWebSearchTools` in
|
||||
* `stores/llm-tool-resolver.ts`). The returned tool reads the web on the model's
|
||||
* `stores/ai/chat-llm/tool-resolver.ts`). The returned tool reads the web on the model's
|
||||
* behalf; results are wrapped as untrusted content and must be paired with
|
||||
* {@link WEB_SEARCH_TOOLSET_PROMPT} in the system prompt.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user