diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts index 129f202cd..270db244d 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.test.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.ts index 8df394ed1..dcc5e78e3 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/built-in.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts index 38fdebd40..4e4585ff5 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.test.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts index db64a8c41..a62bbeeaa 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/mcp.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts index b9d53f1ef..a5259e64f 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.test.ts @@ -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' diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts index fe30c42fa..8a46bc0e9 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/plugins.ts @@ -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' diff --git a/packages/core-agent/src/agents/spark-notify/agent.test.ts b/packages/core-agent/src/agents/spark-notify/agent.test.ts new file mode 100644 index 000000000..a8c5798e9 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/agent.test.ts @@ -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) + }) +}) diff --git a/packages/core-agent/src/agents/spark-notify/agent.ts b/packages/core-agent/src/agents/spark-notify/agent.ts new file mode 100644 index 000000000..58127726f --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/agent.ts @@ -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> + +/** 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 +} + +/** 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 { + 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 } +} diff --git a/packages/core-agent/src/agents/spark-notify/event-source.ts b/packages/core-agent/src/agents/spark-notify/event-source.ts index ca73a04c1..7404e0f5d 100644 --- a/packages/core-agent/src/agents/spark-notify/event-source.ts +++ b/packages/core-agent/src/agents/spark-notify/event-source.ts @@ -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) diff --git a/packages/core-agent/src/agents/spark-notify/handler.test.ts b/packages/core-agent/src/agents/spark-notify/handler.test.ts deleted file mode 100644 index f142222be..000000000 --- a/packages/core-agent/src/agents/spark-notify/handler.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/core-agent/src/agents/spark-notify/handler.ts b/packages/core-agent/src/agents/spark-notify/handler.ts deleted file mode 100644 index 1ea5077a1..000000000 --- a/packages/core-agent/src/agents/spark-notify/handler.ts +++ /dev/null @@ -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 - }, - ) => Promise - /** 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: (name: string, - ) => Promise - /** 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> - /** Replaces the deferred `spark:notify` queue after enqueue/dequeue operations. */ - setPending: (next: Array>) => 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 -} { - 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(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 { - 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, - } -} diff --git a/packages/core-agent/src/agents/spark-notify/index.ts b/packages/core-agent/src/agents/spark-notify/index.ts index b7c9f66bf..52cc58e7d 100644 --- a/packages/core-agent/src/agents/spark-notify/index.ts +++ b/packages/core-agent/src/agents/spark-notify/index.ts @@ -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' diff --git a/packages/core-agent/src/agents/spark-notify/plugins/builtin-tools.ts b/packages/core-agent/src/agents/spark-notify/plugins/builtin-tools.ts new file mode 100644 index 000000000..4d1d9b46d --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/plugins/builtin-tools.ts @@ -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), + } + }, + } +} diff --git a/packages/core-agent/src/agents/spark-notify/plugins/index.ts b/packages/core-agent/src/agents/spark-notify/plugins/index.ts new file mode 100644 index 000000000..76a53b128 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/plugins/index.ts @@ -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' diff --git a/packages/core-agent/src/agents/spark-notify/plugins/observer.ts b/packages/core-agent/src/agents/spark-notify/plugins/observer.ts new file mode 100644 index 000000000..fb3117727 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/plugins/observer.ts @@ -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 + +/** Adds an observer for diagnostics, telemetry adapters, or test recorders. */ +export function createSparkNotifyObserverPlugin(observer: SparkNotifyRuntimeObserver): SparkNotifyPlugin { + return { + name: 'spark-notify-observer', + prepare: () => ({ onEvent: observer }), + } +} diff --git a/packages/core-agent/src/agents/spark-notify/plugins/reaction.ts b/packages/core-agent/src/agents/spark-notify/plugins/reaction.ts new file mode 100644 index 000000000..6e60107a2 --- /dev/null +++ b/packages/core-agent/src/agents/spark-notify/plugins/reaction.ts @@ -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) + } + }, + } + }, + } +} diff --git a/packages/core-agent/src/agents/spark-notify/tools.ts b/packages/core-agent/src/agents/spark-notify/tools.ts index 2bed1c234..38c515066 100644 --- a/packages/core-agent/src/agents/spark-notify/tools.ts +++ b/packages/core-agent/src/agents/spark-notify/tools.ts @@ -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 - 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 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)}` } diff --git a/packages/core-agent/src/agents/spark-notify/types.ts b/packages/core-agent/src/agents/spark-notify/types.ts index 6c1fc3a8d..207a72eb5 100644 --- a/packages/core-agent/src/agents/spark-notify/types.ts +++ b/packages/core-agent/src/agents/spark-notify/types.ts @@ -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 + /** Normalized provider stream events. */ + onStreamEvent: (event: StreamEvent) => void | Promise +} + +/** 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 +} + +/** 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 + /** 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 | undefined } /** diff --git a/packages/i18n/src/locales/en/tamagotchi/settings.yaml b/packages/i18n/src/locales/en/tamagotchi/settings.yaml index a2986885f..94d137943 100644 --- a/packages/i18n/src/locales/en/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/settings.yaml @@ -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: diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml index 2006f7bfb..8bc352096 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/settings.yaml @@ -49,6 +49,10 @@ devtools: pages: context-flow: title: Context Flow + editor: + title: AIRI 编辑器 + description: 打开独立的 AIRI 编辑器窗口 + button: 打开编辑器 lag-visualizer: title: Lag 可视化 performance-visualizer: diff --git a/packages/stage-layouts/src/composables/useChatToolCallRerun.ts b/packages/stage-layouts/src/composables/useChatToolCallRerun.ts index 7c46ce228..af3a5673e 100644 --- a/packages/stage-layouts/src/composables/useChatToolCallRerun.ts +++ b/packages/stage-layouts/src/composables/useChatToolCallRerun.ts @@ -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 { diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index 12d888798..92f032783 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -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", diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 36fc3e450..e81dc9e4e 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -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' diff --git a/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts b/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts index c4c270fcf..fe4c63ed3 100644 --- a/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts +++ b/packages/stage-ui/src/composables/vision/use-vision-inference.test.ts @@ -12,7 +12,7 @@ vi.mock('pinia', async () => { } }) -vi.mock('../../stores/llm', () => ({ +vi.mock('../../stores/ai/chat-llm/llm', () => ({ useLLM: () => ({ stream, }), diff --git a/packages/stage-ui/src/composables/vision/use-vision-inference.ts b/packages/stage-ui/src/composables/vision/use-vision-inference.ts index 092c93f0f..fa994168d 100644 --- a/packages/stage-ui/src/composables/vision/use-vision-inference.ts +++ b/packages/stage-ui/src/composables/vision/use-vision-inference.ts @@ -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' diff --git a/packages/stage-ui/src/stores/llm.test.ts b/packages/stage-ui/src/stores/ai/chat-llm/llm.test.ts similarity index 98% rename from packages/stage-ui/src/stores/llm.test.ts rename to packages/stage-ui/src/stores/ai/chat-llm/llm.test.ts index 65e7cf850..badd88cc3 100644 --- a/packages/stage-ui/src/stores/llm.test.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/llm.test.ts @@ -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, diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/ai/chat-llm/llm.ts similarity index 98% rename from packages/stage-ui/src/stores/llm.ts rename to packages/stage-ui/src/stores/ai/chat-llm/llm.ts index 159e81bab..9daf6b149 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/llm.ts @@ -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' diff --git a/packages/stage-ui/src/stores/llm-streaming-control.test.ts b/packages/stage-ui/src/stores/ai/chat-llm/streaming-control.test.ts similarity index 93% rename from packages/stage-ui/src/stores/llm-streaming-control.test.ts rename to packages/stage-ui/src/stores/ai/chat-llm/streaming-control.test.ts index 570bf817e..b46f6140e 100644 --- a/packages/stage-ui/src/stores/llm-streaming-control.test.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/streaming-control.test.ts @@ -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) diff --git a/packages/stage-ui/src/stores/llm-streaming-control.ts b/packages/stage-ui/src/stores/ai/chat-llm/streaming-control.ts similarity index 98% rename from packages/stage-ui/src/stores/llm-streaming-control.ts rename to packages/stage-ui/src/stores/ai/chat-llm/streaming-control.ts index cb69a154a..f70d8adca 100644 --- a/packages/stage-ui/src/stores/llm-streaming-control.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/streaming-control.ts @@ -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' diff --git a/packages/stage-ui/src/stores/llm-tool-resolver.test.ts b/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.test.ts similarity index 96% rename from packages/stage-ui/src/stores/llm-tool-resolver.test.ts rename to packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.test.ts index 7266c2a45..904825a57 100644 --- a/packages/stage-ui/src/stores/llm-tool-resolver.test.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.test.ts @@ -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 return { ...actual, createWebSearchTools: createWebSearchToolsMock } }) -vi.mock('./modules/web-search', () => ({ +vi.mock('../../modules/web-search', () => ({ useWebSearchStore: useWebSearchStoreMock, })) diff --git a/packages/stage-ui/src/stores/llm-tool-resolver.ts b/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.ts similarity index 96% rename from packages/stage-ui/src/stores/llm-tool-resolver.ts rename to packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.ts index 6aa9a0b9a..50ea43419 100644 --- a/packages/stage-ui/src/stores/llm-tool-resolver.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/tool-resolver.ts @@ -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) diff --git a/packages/stage-ui/src/stores/llm-tools.test.ts b/packages/stage-ui/src/stores/ai/chat-llm/tools.test.ts similarity index 97% rename from packages/stage-ui/src/stores/llm-tools.test.ts rename to packages/stage-ui/src/stores/ai/chat-llm/tools.test.ts index d740036f6..67159cdf0 100644 --- a/packages/stage-ui/src/stores/llm-tools.test.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/tools.test.ts @@ -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 { diff --git a/packages/stage-ui/src/stores/llm-tools.ts b/packages/stage-ui/src/stores/ai/chat-llm/tools.ts similarity index 100% rename from packages/stage-ui/src/stores/llm-tools.ts rename to packages/stage-ui/src/stores/ai/chat-llm/tools.ts diff --git a/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts b/packages/stage-ui/src/stores/ai/chat-llm/toolset-prompts.test.ts similarity index 94% rename from packages/stage-ui/src/stores/llm-toolset-prompts.test.ts rename to packages/stage-ui/src/stores/ai/chat-llm/toolset-prompts.test.ts index ba1a6574d..4477bceb9 100644 --- a/packages/stage-ui/src/stores/llm-toolset-prompts.test.ts +++ b/packages/stage-ui/src/stores/ai/chat-llm/toolset-prompts.test.ts @@ -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(() => { diff --git a/packages/stage-ui/src/stores/llm-toolset-prompts.ts b/packages/stage-ui/src/stores/ai/chat-llm/toolset-prompts.ts similarity index 100% rename from packages/stage-ui/src/stores/llm-toolset-prompts.ts rename to packages/stage-ui/src/stores/ai/chat-llm/toolset-prompts.ts diff --git a/packages/stage-ui/src/stores/character/orchestrator/index.test.ts b/packages/stage-ui/src/stores/character/orchestrator/index.test.ts index 41d9f67d0..1ee6e4274 100644 --- a/packages/stage-ui/src/stores/character/orchestrator/index.test.ts +++ b/packages/stage-ui/src/stores/character/orchestrator/index.test.ts @@ -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' diff --git a/packages/stage-ui/src/stores/character/orchestrator/spark-notify-agent.ts b/packages/stage-ui/src/stores/character/orchestrator/spark-notify-agent.ts deleted file mode 100644 index c90029735..000000000 --- a/packages/stage-ui/src/stores/character/orchestrator/spark-notify-agent.ts +++ /dev/null @@ -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' diff --git a/packages/stage-ui/src/stores/character/orchestrator/store.ts b/packages/stage-ui/src/stores/character/orchestrator/store.ts index 9907cb0ef..b66ec380c 100644 --- a/packages/stage-ui/src/stores/character/orchestrator/store.ts +++ b/packages/stage-ui/src/stores/character/orchestrator/store.ts @@ -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>>([]) + const scheduledNotifies = ref 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 | 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(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) { diff --git a/packages/stage-ui/src/stores/chat.contract.test.ts b/packages/stage-ui/src/stores/chat.contract.test.ts index b96e191ca..71646915b 100644 --- a/packages/stage-ui/src/stores/chat.contract.test.ts +++ b/packages/stage-ui/src/stores/chat.contract.test.ts @@ -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.', }), diff --git a/packages/stage-ui/src/stores/chat.ts b/packages/stage-ui/src/stores/chat.ts index 7d110848b..76ec5dd59 100644 --- a/packages/stage-ui/src/stores/chat.ts +++ b/packages/stage-ui/src/stores/chat.ts @@ -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' diff --git a/packages/stage-ui/src/stores/markdown-stress.ts b/packages/stage-ui/src/stores/markdown-stress.ts index fa5c33451..f6c915970 100644 --- a/packages/stage-ui/src/stores/markdown-stress.ts +++ b/packages/stage-ui/src/stores/markdown-stress.ts @@ -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' diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge-performance-call.test.ts b/packages/stage-ui/src/stores/mods/api/context-bridge-performance-call.test.ts index b6e8b00d4..d19cb71d5 100644 --- a/packages/stage-ui/src/stores/mods/api/context-bridge-performance-call.test.ts +++ b/packages/stage-ui/src/stores/mods/api/context-bridge-performance-call.test.ts @@ -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: { diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge.ts b/packages/stage-ui/src/stores/mods/api/context-bridge.ts index d8631eed6..59f010bad 100644 --- a/packages/stage-ui/src/stores/mods/api/context-bridge.ts +++ b/packages/stage-ui/src/stores/mods/api/context-bridge.ts @@ -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' diff --git a/packages/stage-ui/src/stores/modules/web-search.test.ts b/packages/stage-ui/src/stores/modules/web-search.test.ts index 5628d4c99..1fdc8b58f 100644 --- a/packages/stage-ui/src/stores/modules/web-search.test.ts +++ b/packages/stage-ui/src/stores/modules/web-search.test.ts @@ -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', () => { diff --git a/packages/stage-ui/src/stores/modules/web-search.ts b/packages/stage-ui/src/stores/modules/web-search.ts index c0d0aa108..cd9dc1853 100644 --- a/packages/stage-ui/src/stores/modules/web-search.ts +++ b/packages/stage-ui/src/stores/modules/web-search.ts @@ -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. diff --git a/packages/stage-ui/src/stores/tool-call-rerun.ts b/packages/stage-ui/src/stores/tool-call-rerun.ts index b4621f70f..4956bc4d1 100644 --- a/packages/stage-ui/src/stores/tool-call-rerun.ts +++ b/packages/stage-ui/src/stores/tool-call-rerun.ts @@ -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 { sessionId?: string diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts index 9f9fb0aa7..4b8b7180d 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.test.ts @@ -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 diff --git a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts index 0c104b6f0..01e45ab27 100644 --- a/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts +++ b/packages/stage-ui/src/tools/character/orchestrator/spark-command.ts @@ -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 diff --git a/packages/stage-ui/src/tools/web-search.ts b/packages/stage-ui/src/tools/web-search.ts index 2b9851c15..37d2af06e 100644 --- a/packages/stage-ui/src/tools/web-search.ts +++ b/packages/stage-ui/src/tools/web-search.ts @@ -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. *