refactor(core-agent): spark:notify, and related refactor
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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,52 +1,224 @@
|
||||
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 } from '@xsai/shared-chat'
|
||||
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[]
|
||||
}
|
||||
|
||||
export interface SparkNotifyAgentDeps {
|
||||
/**
|
||||
* 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?: any[]
|
||||
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
|
||||
@@ -58,12 +230,19 @@ export interface SparkNotifyAgentDeps {
|
||||
| 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
|
||||
}
|
||||
|
||||
@@ -90,19 +269,73 @@ export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
].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 LLM with built-in tools
|
||||
* - 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)` function that applies queue/processing policy and returns generated commands
|
||||
* - `handle(event, control)` function that applies queue/processing policy and returns generated commands
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
@@ -113,9 +346,9 @@ export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
* -> `deps.onReactionDelta`/`deps.onReactionEnd`
|
||||
*/
|
||||
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
handle: (event: WebSocketEventOf<'spark:notify'>) => Promise<SparkNotifyHandleResult | undefined>
|
||||
handle: (event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) => Promise<SparkNotifyHandleResult | undefined>
|
||||
} {
|
||||
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
|
||||
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
|
||||
const activeProvider = deps.getActiveProvider()
|
||||
const activeModel = deps.getActiveModel()
|
||||
if (!activeProvider || !activeModel) {
|
||||
@@ -123,9 +356,9 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const runtimePolicy = resolveSparkNotifyRuntimePolicy(control)
|
||||
const chatProvider = await deps.getProviderInstance<ChatProvider>(activeProvider)
|
||||
const commandDrafts: SparkNotifyCommandDraft[] = []
|
||||
|
||||
let noResponse = false
|
||||
|
||||
const { tools } = await createSparkNotifyTools({
|
||||
@@ -133,6 +366,9 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
noResponse = true
|
||||
},
|
||||
onCommands: commands => commandDrafts.push(...commands),
|
||||
onTrace: deps.onTrace,
|
||||
allowNoResponse: runtimePolicy.allowNoResponse,
|
||||
allowSparkCommand: runtimePolicy.allowSparkCommand,
|
||||
})
|
||||
|
||||
const systemMessage: Message = {
|
||||
@@ -140,40 +376,123 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
content: [
|
||||
deps.getSystemPrompt(),
|
||||
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
|
||||
...(control?.messageOverride?.appendSystemInstructions ?? []),
|
||||
].filter(Boolean).join('\n\n'),
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
notify: event.data,
|
||||
source: event.source,
|
||||
}, null, 2),
|
||||
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, [systemMessage, userMessage], {
|
||||
await deps.stream(activeModel, chatProvider, messages, {
|
||||
tools,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
supportsTools: runtimePolicy.supportsTools,
|
||||
waitForTools: runtimePolicy.waitForTools,
|
||||
toolChoice: runtimePolicy.toolChoice,
|
||||
onStreamEvent: async (streamEvent: StreamEvent) => {
|
||||
if (streamEvent.type === 'text-delta') {
|
||||
if (noResponse)
|
||||
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 += 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, '')
|
||||
return
|
||||
}
|
||||
|
||||
deps.onReactionEnd(event.data.id, fullText)
|
||||
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')
|
||||
@@ -181,13 +500,28 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
},
|
||||
})
|
||||
|
||||
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: fullText.trim(),
|
||||
reaction,
|
||||
commands: commandDrafts,
|
||||
} satisfies SparkNotifyResponse
|
||||
}
|
||||
|
||||
async function handle(event: WebSocketEventOf<'spark:notify'>): Promise<SparkNotifyHandleResult | undefined> {
|
||||
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
|
||||
@@ -200,7 +534,7 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
deps.setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await runNotifyAgent(event)
|
||||
const response = await runNotifyAgent(event, control)
|
||||
if (!response)
|
||||
return undefined
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
SparkNotifyCommandEvent,
|
||||
SparkNotifyHandleResult,
|
||||
SparkNotifyResponse,
|
||||
SparkTraceCapture,
|
||||
} from './handler'
|
||||
export {
|
||||
getSparkNotifyHandlingAgentInstruction,
|
||||
@@ -18,3 +19,10 @@ export type {
|
||||
SparkNotifyCommandDraft,
|
||||
} from './tools'
|
||||
export { createSparkNotifyTools } from './tools'
|
||||
export type {
|
||||
SparkNotifyMessageOverride,
|
||||
SparkNotifyResponseControl,
|
||||
SparkNotifyRuntimePolicy,
|
||||
SparkNotifyTracingHooks,
|
||||
SparkTraceEvent,
|
||||
} from './types'
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { ContextUpdate } from '@proj-airi/server-sdk'
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type {
|
||||
SparkNotifyTracingHooks,
|
||||
SparkTraceEvent,
|
||||
} from './types'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { rawTool } from '@xsai/tool'
|
||||
@@ -10,42 +16,61 @@ import {
|
||||
sparkNotifyCommandSchema,
|
||||
} from './schema'
|
||||
|
||||
/**
|
||||
* Normalized `builtIn_sparkCommand` payload forwarded to downstream orchestrators.
|
||||
*/
|
||||
export interface SparkNotifyCommandDraft {
|
||||
/** Target agent or lane identifiers that should receive the emitted command. */
|
||||
destinations: string[]
|
||||
/** Optional interrupt mode used by downstream schedulers. */
|
||||
interrupt?: 'force' | 'soft' | boolean
|
||||
/** Optional command priority used by downstream schedulers. */
|
||||
priority?: 'critical' | 'high' | 'normal' | 'low'
|
||||
/** Optional intent describing why the downstream agent should process the command. */
|
||||
intent?: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
|
||||
/** Optional acknowledgement text that may be surfaced by the downstream consumer. */
|
||||
ack?: string
|
||||
/** Optional structured guidance generated by the notify agent for the downstream command target. */
|
||||
guidance?: {
|
||||
/** Guidance mode describing how the downstream agent should interpret the options. */
|
||||
type: 'proposal' | 'instruction' | 'memory-recall'
|
||||
/** Trait-strength map that hints at which persona qualities the downstream agent should favor. */
|
||||
persona?: Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>
|
||||
/** Candidate options the downstream agent may choose from while executing the command. */
|
||||
options: Array<{
|
||||
/** Human-readable label for the candidate option. */
|
||||
label: string
|
||||
/** Ordered steps the downstream agent should follow for the option. */
|
||||
steps: string[]
|
||||
/** Optional rationale explaining why this option is suggested. */
|
||||
rationale?: string
|
||||
/** Optional possible outcomes the downstream agent should anticipate. */
|
||||
possibleOutcome?: string[]
|
||||
/** Optional qualitative risk level for the option. */
|
||||
risk?: 'high' | 'medium' | 'low' | 'none'
|
||||
/** Optional fallback steps the downstream agent may use if the option fails. */
|
||||
fallback?: string[]
|
||||
/** Optional trigger cues indicating when the option should be selected. */
|
||||
triggers?: string[]
|
||||
}>
|
||||
}
|
||||
/** Optional context patches that should accompany the emitted command. */
|
||||
contexts?: ContextUpdate<Record<string, unknown>, undefined>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registration options for the spark-notify runtime.
|
||||
*/
|
||||
export interface CreateSparkNotifyToolsOptions {
|
||||
/** Receives validated command drafts emitted by `builtIn_sparkCommand`. */
|
||||
onCommands: (commands: SparkNotifyCommandDraft[]) => void
|
||||
/** Receives the no-response signal emitted by `builtIn_sparkNoResponse`. */
|
||||
onNoResponse: () => void
|
||||
/**
|
||||
* Whether to expose the `builtIn_sparkNoResponse` tool.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
/** Receives trace events describing tool exposure and execution. */
|
||||
onTrace?: SparkNotifyTracingHooks['onTrace']
|
||||
/** Enables or disables registration of the `builtIn_sparkNoResponse` tool. */
|
||||
allowNoResponse?: boolean
|
||||
/**
|
||||
* Whether to expose the `builtIn_sparkCommand` tool.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
/** Enables or disables registration of the `builtIn_sparkCommand` tool. */
|
||||
allowSparkCommand?: boolean
|
||||
}
|
||||
|
||||
@@ -107,20 +132,86 @@ function normalizeSparkNotifyCommand(
|
||||
* - Tool array consumable by `@xsai/stream-text`
|
||||
*/
|
||||
export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOptions) {
|
||||
const { allowNoResponse = true, allowSparkCommand = true } = options
|
||||
const allowNoResponse = options.allowNoResponse !== false
|
||||
const allowSparkCommand = options.allowSparkCommand !== false
|
||||
|
||||
const tools: Array<ReturnType<typeof rawTool>> = []
|
||||
const tools: Tool[] = []
|
||||
|
||||
if (allowNoResponse) {
|
||||
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 () => {
|
||||
options.onNoResponse()
|
||||
return 'AIRI System: Acknowledged, no response or action will be processed.'
|
||||
},
|
||||
}))
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
if (allowSparkCommand) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ToolChoice } from '@xsai/shared-chat'
|
||||
|
||||
/**
|
||||
* Runtime-only prompt hints used to reshape how one spark event is serialized for the model.
|
||||
*
|
||||
* These overrides are intentionally kept out of transport protocol types.
|
||||
* They are host-local rendering hints, not part of the canonical spark event payload.
|
||||
*/
|
||||
export interface SparkNotifyMessageOverride {
|
||||
/**
|
||||
* Additional system instructions appended after the base spark-notify instruction block.
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
appendSystemInstructions?: string[]
|
||||
/**
|
||||
* Additional serialized sections appended after the default user payload serialization.
|
||||
*
|
||||
* Use when:
|
||||
* - A host wants to inject a pre-rendered message fragment for one run
|
||||
* - A plugin temporarily needs extra readable context without changing the protocol schema
|
||||
*
|
||||
* Expects:
|
||||
* - Entries already serialized into provider-safe text
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
appendUserSections?: string[]
|
||||
/**
|
||||
* Replaces the default JSON user payload serialization entirely for one run.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
replaceUserMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-provided overrides that shape how the `spark:notify` runtime must respond.
|
||||
*/
|
||||
export interface SparkNotifyResponseControl {
|
||||
/**
|
||||
* Forces the runtime to produce some output instead of choosing the no-response tool.
|
||||
*
|
||||
* Use when:
|
||||
* - The host requires a visible or actionable outcome for the notify event
|
||||
*
|
||||
* Expects:
|
||||
* - Text output and spark-command tool calls are both still allowed
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
forceResponse?: boolean
|
||||
/**
|
||||
* Forces a text reaction and disables spark-command tool use for the current notify event.
|
||||
*
|
||||
* Use when:
|
||||
* - The host wants a spoken or visible reaction only
|
||||
* - Tool execution would be unsafe or unnecessary for this run
|
||||
*
|
||||
* Expects:
|
||||
* - This takes precedence over `forceSparkCommandResponse` when both are set
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
forceTextResponse?: boolean
|
||||
/**
|
||||
* Forces a spark-command tool response and suppresses free-form text output for the current notify event.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs the notify run to emit downstream commands only
|
||||
* - Reaction text should not leak into the user-visible channel
|
||||
*
|
||||
* Expects:
|
||||
* - The runtime exposes tools and waits for tool execution before completing
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
forceSparkCommandResponse?: boolean
|
||||
/**
|
||||
* Host-local message serialization override applied only while rendering the current notify turn.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
messageOverride?: SparkNotifyMessageOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace event emitted by the spark-notify runtime.
|
||||
*/
|
||||
export interface SparkTraceEvent {
|
||||
/** Trace event category describing which stage of the notify run emitted the payload. */
|
||||
type:
|
||||
| 'messages-rendered'
|
||||
| 'tools-prepared'
|
||||
| 'model-input'
|
||||
| 'model-output-text'
|
||||
| 'model-output-tool-call'
|
||||
| 'tool-execution'
|
||||
| 'result'
|
||||
/** JSON-serializable trace payload attached to the selected trace event category. */
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved runtime response policy derived from `SparkNotifyResponseControl`.
|
||||
*/
|
||||
export interface SparkNotifyRuntimePolicy {
|
||||
/** Whether the `builtIn_sparkNoResponse` tool is exposed for the current run. */
|
||||
allowNoResponse: boolean
|
||||
/** Whether the `builtIn_sparkCommand` tool is exposed for the current run. */
|
||||
allowSparkCommand: boolean
|
||||
/** Whether the provider call should include any tools at all. */
|
||||
supportsTools: boolean
|
||||
/** Whether the runtime should wait for tool execution before treating the call as complete. */
|
||||
waitForTools: boolean
|
||||
/** Explicit tool-choice directive forwarded to the provider, when command emission is mandatory. */
|
||||
toolChoice?: ToolChoice
|
||||
/** Whether free-form text deltas should be ignored after rendering the provider response. */
|
||||
ignoreTextOutput: boolean
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Message, RawMessage } from './types'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compactConversationEntries } from './compaction'
|
||||
|
||||
describe('compactConversationEntries', () => {
|
||||
it('compacts older chess turns while preserving recent move-reaction pairs', () => {
|
||||
const result = compactConversationEntries({
|
||||
entries: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'weather?',
|
||||
} satisfies RawMessage,
|
||||
{
|
||||
id: 'history-1',
|
||||
role: 'event',
|
||||
segments: [
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: false,
|
||||
items: [
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 1,
|
||||
actor: 'player',
|
||||
action: {
|
||||
kind: 'move-played',
|
||||
san: 'e4',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'reaction',
|
||||
reactionType: 'spark-command',
|
||||
text: 'Hmm.',
|
||||
},
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 2,
|
||||
actor: 'assistant',
|
||||
action: {
|
||||
kind: 'move-executed',
|
||||
san: 'e5',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'reaction',
|
||||
reactionType: 'spark-command',
|
||||
text: 'Let us answer.',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies Message,
|
||||
],
|
||||
recentTurnLimit: 1,
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(JSON.stringify(result)).toContain('Let us answer.')
|
||||
expect(JSON.stringify(result)).toContain('compacted')
|
||||
expect(JSON.stringify(result)).toContain('board-updated')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { HistoryItem, Message, MessageHistoryBlockSegment, RawMessage } from './types'
|
||||
|
||||
/**
|
||||
* Options for compacting projected conversation history.
|
||||
*/
|
||||
export interface CompactConversationEntriesOptions {
|
||||
/** Ordered conversation entries to compact in place. */
|
||||
entries: Array<Message | RawMessage>
|
||||
/** Maximum number of explicit `turn` items to preserve inside each history block. */
|
||||
recentTurnLimit: number
|
||||
/** Optional domain-aware summary formatter used for removed history windows. */
|
||||
summarizeCompactedHistory?: (input: {
|
||||
removedTurnCount: number
|
||||
originalItems: HistoryItem[]
|
||||
keptItems: HistoryItem[]
|
||||
}) => string
|
||||
}
|
||||
|
||||
function isStructuredMessage(entry: Message | RawMessage): entry is Message {
|
||||
return 'segments' in entry
|
||||
}
|
||||
|
||||
function countTurns(items: HistoryItem[]) {
|
||||
return items.reduce((count, item) => count + (item.type === 'turn' ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function keepRecentHistoryItems(items: HistoryItem[], recentTurnLimit: number) {
|
||||
const keptItems: HistoryItem[] = []
|
||||
let turnCount = 0
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
keptItems.unshift(items[index])
|
||||
if (items[index].type === 'turn')
|
||||
turnCount += 1
|
||||
|
||||
if (turnCount >= recentTurnLimit)
|
||||
break
|
||||
}
|
||||
|
||||
return keptItems
|
||||
}
|
||||
|
||||
function compactHistoryBlock(
|
||||
segment: MessageHistoryBlockSegment,
|
||||
recentTurnLimit: number,
|
||||
summarizeCompactedHistory?: (input: {
|
||||
removedTurnCount: number
|
||||
originalItems: HistoryItem[]
|
||||
keptItems: HistoryItem[]
|
||||
}) => string,
|
||||
): MessageHistoryBlockSegment {
|
||||
if (segment.compacted)
|
||||
return segment
|
||||
|
||||
const historyTurnCount = countTurns(segment.items)
|
||||
if (historyTurnCount <= recentTurnLimit)
|
||||
return segment
|
||||
|
||||
const keptItems = keepRecentHistoryItems(segment.items, recentTurnLimit)
|
||||
const removedTurnCount = historyTurnCount - recentTurnLimit
|
||||
|
||||
return {
|
||||
type: 'history-block',
|
||||
compacted: true,
|
||||
items: [
|
||||
{
|
||||
type: 'summary',
|
||||
text: summarizeCompactedHistory?.({
|
||||
removedTurnCount,
|
||||
originalItems: segment.items,
|
||||
keptItems,
|
||||
}) ?? `Compacted ${removedTurnCount} older turns with paired reactions.`,
|
||||
fromTurnIndex: segment.items.find(item => item.type === 'turn')?.type === 'turn'
|
||||
? (segment.items.find(item => item.type === 'turn')?.turnIndex ?? undefined)
|
||||
: undefined,
|
||||
toTurnIndex: keptItems.findLast(item => item.type === 'turn')?.type === 'turn'
|
||||
? keptItems.findLast(item => item.type === 'turn')?.turnIndex
|
||||
: undefined,
|
||||
},
|
||||
...keptItems,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compacts older structured history blocks while preserving recent turn/reaction pairs.
|
||||
*
|
||||
* Use when:
|
||||
* - Long-running conversations need a smaller prompt footprint
|
||||
* - Domain history must keep recent turn/reaction pairs intact without hardcoding one plugin format
|
||||
*
|
||||
* Expects:
|
||||
* - History blocks to be ordered chronologically
|
||||
* - `recentTurnLimit` to be a positive integer
|
||||
* - `summarizeCompactedHistory`, when provided, returns domain-specific summary text for removed history
|
||||
*
|
||||
* Returns:
|
||||
* - A new entry array with eligible history blocks compacted in place
|
||||
*/
|
||||
export function compactConversationEntries(input: CompactConversationEntriesOptions): Array<Message | RawMessage> {
|
||||
if (input.recentTurnLimit <= 0)
|
||||
return input.entries
|
||||
|
||||
return input.entries.map((entry) => {
|
||||
if (!isStructuredMessage(entry))
|
||||
return entry
|
||||
|
||||
return {
|
||||
...entry,
|
||||
segments: entry.segments.map((segment) => {
|
||||
if (segment.type !== 'history-block')
|
||||
return segment
|
||||
|
||||
return compactHistoryBlock(segment, input.recentTurnLimit, input.summarizeCompactedHistory)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export { compactConversationEntries } from './compaction'
|
||||
export type {
|
||||
Projection,
|
||||
ProjectionCompactedHistory,
|
||||
ProjectionDomainEvent,
|
||||
ProjectionSessionUserTurn,
|
||||
ProjectionSparkCommand,
|
||||
ProjectionSparkNotify,
|
||||
} from './projection'
|
||||
export { projectConversationEntries, projectProjection } from './projection'
|
||||
export { renderProviderChatMessages } from './render-provider-chat'
|
||||
export type {
|
||||
HistoryItem,
|
||||
HistoryItemDomainEvent,
|
||||
HistoryReaction,
|
||||
HistorySummary,
|
||||
HistoryTurn,
|
||||
HistoryTurnAction,
|
||||
HistoryTurnEventAction,
|
||||
HistoryTurnGenericAction,
|
||||
HistoryTurnMoveAction,
|
||||
HistoryTurnTextAction,
|
||||
Message,
|
||||
MessageDomainEventSegment,
|
||||
MessageHistoryBlockSegment,
|
||||
MessageInstructionSegment,
|
||||
MessageReferenceSegment,
|
||||
MessageSegment,
|
||||
MessageStateSnapshotSegment,
|
||||
MessageSummarySegment,
|
||||
MessageTaggedTextSegment,
|
||||
MessageTextSegment,
|
||||
RawMessage,
|
||||
SegmentDomainEvent,
|
||||
SegmentHistoryBlock,
|
||||
SegmentInstruction,
|
||||
SegmentReference,
|
||||
SegmentStateSnapshot,
|
||||
SegmentSummary,
|
||||
SegmentTaggedText,
|
||||
SegmentText,
|
||||
} from './types'
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Message, RawMessage } from './types'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { projectConversationEntries, projectProjection } from './projection'
|
||||
|
||||
describe('projectProjection', () => {
|
||||
it('projects a domain event into a structured event message', () => {
|
||||
const result = projectProjection({
|
||||
type: 'domain-event',
|
||||
id: 'event-1',
|
||||
domain: 'chess',
|
||||
name: 'move-resolved',
|
||||
payload: {
|
||||
moveSan: 'e4',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const projected = result[0] as Message
|
||||
expect(projected.role).toBe('event')
|
||||
expect(projected.segments[0].type).toBe('domain-event')
|
||||
expect(projected.segments[1].type).toBe('reference')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectConversationEntries', () => {
|
||||
it('keeps existing entries before projected entries', () => {
|
||||
const entries: Array<Message | RawMessage> = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system',
|
||||
},
|
||||
]
|
||||
|
||||
const result = projectConversationEntries({
|
||||
entries,
|
||||
projections: [
|
||||
{
|
||||
type: 'session-user-turn',
|
||||
id: 'turn-1',
|
||||
content: 'hello',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toBe(entries[0])
|
||||
expect(result[1].role).toBe('user')
|
||||
})
|
||||
|
||||
it('projects spark notify and command payloads into structured segments', () => {
|
||||
const result = projectConversationEntries({
|
||||
entries: [],
|
||||
projections: [
|
||||
{
|
||||
type: 'spark-notify',
|
||||
id: 'notify-1',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
headline: 'chess update',
|
||||
note: 'Project a board update',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
destinations: ['character'],
|
||||
},
|
||||
{
|
||||
type: 'spark-command',
|
||||
id: 'command-1',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
commandId: 'command-1',
|
||||
parentEventId: 'notify-1',
|
||||
intent: 'action',
|
||||
ack: 'play e5',
|
||||
destinations: ['character'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
const notify = result[0] as Message
|
||||
const command = result[1] as Message
|
||||
expect(notify.segments[0].type).toBe('instruction')
|
||||
expect(notify.segments[1].type).toBe('tagged-text')
|
||||
expect(notify.segments[2].type).toBe('reference')
|
||||
expect(command.segments[0].type).toBe('instruction')
|
||||
expect(command.segments[1].type).toBe('tagged-text')
|
||||
expect(command.segments[2].type).toBe('state-snapshot')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { HistoryItem, Message, RawMessage } from './types'
|
||||
|
||||
/**
|
||||
* Projection payload for one user-authored session turn.
|
||||
*/
|
||||
export interface ProjectionSessionUserTurn {
|
||||
type: 'session-user-turn'
|
||||
id: string
|
||||
content: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection payload for one `spark:notify` event.
|
||||
*/
|
||||
export interface ProjectionSparkNotify {
|
||||
type: 'spark-notify'
|
||||
id: string
|
||||
source: string
|
||||
headline: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
destinations: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection payload for one `spark:command` event.
|
||||
*/
|
||||
export interface ProjectionSparkCommand {
|
||||
type: 'spark-command'
|
||||
id: string
|
||||
source?: string
|
||||
commandId: string
|
||||
parentEventId?: string
|
||||
intent?: string
|
||||
ack?: string
|
||||
destinations: string[]
|
||||
guidance?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection payload for one structured domain event.
|
||||
*/
|
||||
export interface ProjectionDomainEvent {
|
||||
type: 'domain-event'
|
||||
id: string
|
||||
domain: string
|
||||
name?: string
|
||||
payload: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection payload for one already-compacted history block.
|
||||
*/
|
||||
export interface ProjectionCompactedHistory {
|
||||
type: 'compacted-history'
|
||||
id: string
|
||||
source?: string
|
||||
summary?: string
|
||||
items: HistoryItem[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of projection payloads accepted by the generic message projection pipeline.
|
||||
*/
|
||||
export type Projection
|
||||
= ProjectionSessionUserTurn
|
||||
| ProjectionSparkNotify
|
||||
| ProjectionSparkCommand
|
||||
| ProjectionDomainEvent
|
||||
| ProjectionCompactedHistory
|
||||
|
||||
function toInstructionSegment(text: string, priority?: 'low' | 'normal' | 'high' | 'critical') {
|
||||
return {
|
||||
type: 'instruction',
|
||||
text,
|
||||
priority,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toTaggedTextSegment(tag: string, text: string) {
|
||||
return {
|
||||
type: 'tagged-text',
|
||||
tag,
|
||||
text,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toDomainEventSegment(eventType: string, payload: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'domain-event',
|
||||
eventType,
|
||||
payload,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toStateSnapshotSegment(stateType: string, payload: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'state-snapshot',
|
||||
stateType,
|
||||
payload,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toSummarySegment(text: string, metadata?: Record<string, unknown>) {
|
||||
return {
|
||||
type: 'summary',
|
||||
text,
|
||||
metadata,
|
||||
} as const
|
||||
}
|
||||
|
||||
function toReferenceSegment(refType: string, targetId: string, note?: string) {
|
||||
return {
|
||||
type: 'reference',
|
||||
refType,
|
||||
targetId,
|
||||
note,
|
||||
} as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a projection into one or more conversation entries.
|
||||
*
|
||||
* Use when:
|
||||
* - You need to append structured projection output to a conversation stream
|
||||
* - You want a single projection pipeline to handle session, spark, and domain inputs
|
||||
*
|
||||
* Expects:
|
||||
* - Projection payloads to already be normalized
|
||||
*
|
||||
* Returns:
|
||||
* - Provider-ready raw messages or structured messages in stable order
|
||||
*/
|
||||
export function projectProjection(projection: Projection): Array<Message | RawMessage> {
|
||||
if (projection.type === 'session-user-turn') {
|
||||
return [{
|
||||
role: 'user',
|
||||
content: projection.content,
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'compacted-history') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.source,
|
||||
segments: [
|
||||
toSummarySegment(projection.summary ?? 'Compacted history block.'),
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: true,
|
||||
items: projection.items,
|
||||
},
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'domain-event') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.domain,
|
||||
segments: [
|
||||
toDomainEventSegment(projection.name ?? projection.domain, projection.payload),
|
||||
toReferenceSegment('domain', projection.domain, projection.name),
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
if (projection.type === 'spark-notify') {
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.source,
|
||||
segments: [
|
||||
toInstructionSegment(`Handle spark notify from ${projection.source}.`),
|
||||
toTaggedTextSegment(
|
||||
'spark-notify',
|
||||
[
|
||||
`Headline: ${projection.headline}.`,
|
||||
projection.note ? `Note: ${projection.note}` : undefined,
|
||||
projection.payload ? `Payload: ${JSON.stringify(projection.payload, null, 2)}` : undefined,
|
||||
projection.destinations.length > 0 ? `Destinations: ${projection.destinations.join(', ')}.` : undefined,
|
||||
].filter(Boolean).join('\n'),
|
||||
),
|
||||
toReferenceSegment('source', projection.source),
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
return [{
|
||||
id: projection.id,
|
||||
role: 'event',
|
||||
source: projection.source,
|
||||
segments: [
|
||||
toInstructionSegment(`Execute spark command ${projection.commandId}.`, 'high'),
|
||||
toTaggedTextSegment(
|
||||
'spark-command',
|
||||
[
|
||||
projection.source ? `Source: ${projection.source}.` : undefined,
|
||||
projection.parentEventId ? `Parent event: ${projection.parentEventId}.` : undefined,
|
||||
projection.intent ? `Intent: ${projection.intent}.` : undefined,
|
||||
projection.ack ? `Ack: ${projection.ack}.` : undefined,
|
||||
projection.guidance ? `Guidance: ${JSON.stringify(projection.guidance, null, 2)}` : undefined,
|
||||
projection.destinations.length > 0 ? `Destinations: ${projection.destinations.join(', ')}.` : undefined,
|
||||
].filter(Boolean).join('\n'),
|
||||
),
|
||||
toStateSnapshotSegment('spark-command', {
|
||||
commandId: projection.commandId,
|
||||
parentEventId: projection.parentEventId,
|
||||
destinations: projection.destinations,
|
||||
}),
|
||||
],
|
||||
metadata: projection.metadata,
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects raw or structured entries into a single ordered conversation list.
|
||||
*
|
||||
* Use when:
|
||||
* - Building a full provider prompt from session messages and structured projections
|
||||
* - Appending projected session, spark, or domain events to history
|
||||
*
|
||||
* Expects:
|
||||
* - Inputs already ordered by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - The original entries followed by projection-derived entries
|
||||
*/
|
||||
export function projectConversationEntries(input: {
|
||||
entries: Array<Message | RawMessage>
|
||||
projections: Projection[]
|
||||
}): Array<Message | RawMessage> {
|
||||
return [
|
||||
...input.entries,
|
||||
...input.projections.flatMap(projectProjection),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Message, RawMessage } from './types'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { renderProviderChatMessages } from './render-provider-chat'
|
||||
|
||||
describe('renderProviderChatMessages', () => {
|
||||
it('renders structured event messages into raw provider chat messages', () => {
|
||||
const entries: Array<Message | RawMessage> = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system prompt',
|
||||
},
|
||||
{
|
||||
id: 'event-1',
|
||||
role: 'event',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
segments: [
|
||||
{
|
||||
type: 'instruction',
|
||||
text: 'Keep the reply short.',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Chess update',
|
||||
},
|
||||
{
|
||||
type: 'tagged-text',
|
||||
tag: 'agent_spark_command_reaction',
|
||||
text: 'Move accepted.',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'state-snapshot',
|
||||
stateType: 'board',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: true,
|
||||
items: [
|
||||
{
|
||||
type: 'summary',
|
||||
text: 'Compacted history.',
|
||||
fromTurnIndex: 1,
|
||||
toTurnIndex: 3,
|
||||
},
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 3,
|
||||
actor: 'assistant',
|
||||
action: {
|
||||
kind: 'move-executed',
|
||||
san: 'e5',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'summary',
|
||||
text: 'Earlier turns compacted.',
|
||||
metadata: {
|
||||
span: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'reference',
|
||||
refType: 'turn',
|
||||
targetId: 'turn-2',
|
||||
note: 'Recent move',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const rendered = renderProviderChatMessages({
|
||||
entries,
|
||||
mode: 'session-spark-notify',
|
||||
})
|
||||
|
||||
expect(rendered).toHaveLength(2)
|
||||
expect(rendered[0].content).toBe('system prompt')
|
||||
expect(rendered[1].role).toBe('system')
|
||||
expect(rendered[1].content).toContain('Instruction [critical]:')
|
||||
expect(rendered[1].content).toContain('<agent_spark_command_reaction>Move accepted.</agent_spark_command_reaction>')
|
||||
expect(rendered[1].content).toContain('Domain event: board-updated')
|
||||
expect(rendered[1].content).toContain('State snapshot: board')
|
||||
expect(rendered[1].content).toContain('Summary:')
|
||||
expect(rendered[1].content).toContain('Reference: turn -> turn-2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { HistoryItem, Message, MessageSegment, RawMessage } from './types'
|
||||
|
||||
function renderHistoryAction(item: HistoryItem) {
|
||||
if (item.type === 'summary') {
|
||||
return [
|
||||
'Summary:',
|
||||
item.text,
|
||||
item.fromTurnIndex != null || item.toTurnIndex != null
|
||||
? `Window: ${item.fromTurnIndex ?? '?'} -> ${item.toTurnIndex ?? '?'}.`
|
||||
: undefined,
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
if (item.type === 'reaction')
|
||||
return `${item.reactionType}: ${item.text}`
|
||||
|
||||
if (item.type === 'domain-event') {
|
||||
return [
|
||||
`Domain event: ${item.eventType}`,
|
||||
JSON.stringify(item.payload, null, 2),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (item.action.kind === 'text')
|
||||
return item.action.text
|
||||
|
||||
if (item.action.kind === 'event')
|
||||
return `${item.action.name}${item.action.payload ? ` ${JSON.stringify(item.action.payload)}` : ''}`
|
||||
|
||||
if (item.action.kind === 'move-played' || item.action.kind === 'move-executed')
|
||||
return `${item.action.kind} ${item.action.san}`
|
||||
|
||||
return JSON.stringify(item.action)
|
||||
}
|
||||
|
||||
function renderSegmentText(segment: MessageSegment): string {
|
||||
if (segment.type === 'text')
|
||||
return segment.text
|
||||
|
||||
if (segment.type === 'instruction') {
|
||||
return [
|
||||
segment.priority ? `Instruction [${segment.priority}]:` : 'Instruction:',
|
||||
segment.text,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (segment.type === 'tagged-text')
|
||||
return `<${segment.tag}>${segment.text}</${segment.tag}>`
|
||||
|
||||
if (segment.type === 'domain-event') {
|
||||
return [
|
||||
`Domain event: ${segment.eventType}`,
|
||||
JSON.stringify(segment.payload, null, 2),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (segment.type === 'state-snapshot') {
|
||||
return [
|
||||
`State snapshot: ${segment.stateType}`,
|
||||
JSON.stringify(segment.payload, null, 2),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (segment.type === 'summary') {
|
||||
return [
|
||||
'Summary:',
|
||||
segment.text,
|
||||
segment.metadata ? JSON.stringify(segment.metadata, null, 2) : undefined,
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
if (segment.type === 'reference') {
|
||||
return [
|
||||
`Reference: ${segment.refType} -> ${segment.targetId}`,
|
||||
segment.note,
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
return segment.items.map(renderHistoryAction).join('\n')
|
||||
}
|
||||
|
||||
function mapStructuredRole(role: Message['role']): RawMessage['role'] {
|
||||
if (role === 'context' || role === 'event' || role === 'summary')
|
||||
return 'system'
|
||||
|
||||
return role
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders structured messages into provider chat messages with stable ordering.
|
||||
*
|
||||
* Use when:
|
||||
* - Preparing a chat completion input array
|
||||
* - Projected messages must be flattened into raw provider chat text without leaking domain-specific renderer logic
|
||||
*
|
||||
* Expects:
|
||||
* - Structured messages to contain renderable segments
|
||||
* - `mode` to describe the prompt surface, even when rendering stays identical
|
||||
*
|
||||
* Returns:
|
||||
* - Raw provider chat messages in the same order as the input entries
|
||||
*/
|
||||
export function renderProviderChatMessages(input: {
|
||||
entries: Array<Message | RawMessage>
|
||||
mode: 'session-main' | 'session-spark-notify' | 'session-spark-command' | 'eval-debug'
|
||||
}): RawMessage[] {
|
||||
const attachSourceName = input.mode !== 'session-main'
|
||||
|
||||
return input.entries.map((entry) => {
|
||||
if ('content' in entry)
|
||||
return entry
|
||||
|
||||
return {
|
||||
role: mapStructuredRole(entry.role),
|
||||
content: entry.segments.map(renderSegmentText).join('\n'),
|
||||
name: attachSourceName ? entry.source : undefined,
|
||||
metadata: entry.metadata,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
HistoryItem,
|
||||
Message,
|
||||
RawMessage,
|
||||
SegmentDomainEvent,
|
||||
SegmentHistoryBlock,
|
||||
SegmentInstruction,
|
||||
SegmentReference,
|
||||
SegmentStateSnapshot,
|
||||
SegmentSummary,
|
||||
SegmentTaggedText,
|
||||
SegmentText,
|
||||
} from './types'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('message types', () => {
|
||||
it('supports structured history blocks and provider-ready raw messages', () => {
|
||||
const history: HistoryItem[] = [
|
||||
{
|
||||
type: 'turn',
|
||||
turnType: 'chess',
|
||||
turnIndex: 1,
|
||||
actor: 'player',
|
||||
action: {
|
||||
kind: 'move-played',
|
||||
san: 'e4',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'reaction',
|
||||
reactionType: 'spark-command',
|
||||
text: 'Good move.',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const segments: Array<
|
||||
SegmentText
|
||||
| SegmentInstruction
|
||||
| SegmentTaggedText
|
||||
| SegmentDomainEvent
|
||||
| SegmentStateSnapshot
|
||||
| SegmentHistoryBlock
|
||||
| SegmentSummary
|
||||
| SegmentReference
|
||||
> = [
|
||||
{
|
||||
type: 'instruction',
|
||||
text: 'Explain the current board state.',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'tagged-text',
|
||||
tag: 'agent_spark_command_reaction',
|
||||
text: 'Good move.',
|
||||
},
|
||||
{
|
||||
type: 'domain-event',
|
||||
eventType: 'board-updated',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'state-snapshot',
|
||||
stateType: 'board',
|
||||
payload: {
|
||||
fen: 'startpos',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'summary',
|
||||
text: 'Older chess turns compacted.',
|
||||
},
|
||||
{
|
||||
type: 'reference',
|
||||
refType: 'turn',
|
||||
targetId: 'turn-1',
|
||||
note: 'Latest paired move',
|
||||
},
|
||||
{
|
||||
type: 'history-block',
|
||||
compacted: false,
|
||||
items: history,
|
||||
},
|
||||
]
|
||||
|
||||
const structuredMessage: Message = {
|
||||
id: 'msg-1',
|
||||
role: 'event',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
segments,
|
||||
metadata: {
|
||||
domain: 'chess',
|
||||
},
|
||||
}
|
||||
|
||||
const rawMessage: RawMessage = {
|
||||
role: 'user',
|
||||
content: 'continue',
|
||||
metadata: {
|
||||
source: 'session',
|
||||
},
|
||||
}
|
||||
|
||||
const historyBlock = structuredMessage.segments[6] as SegmentHistoryBlock
|
||||
expect(structuredMessage.segments).toHaveLength(7)
|
||||
expect(structuredMessage.segments[0].type).toBe('instruction')
|
||||
expect(structuredMessage.segments[1].type).toBe('tagged-text')
|
||||
expect(structuredMessage.segments[2].type).toBe('domain-event')
|
||||
expect(structuredMessage.segments[3].type).toBe('state-snapshot')
|
||||
expect(structuredMessage.segments[4].type).toBe('summary')
|
||||
expect(structuredMessage.segments[5].type).toBe('reference')
|
||||
expect(structuredMessage.segments[6].type).toBe('history-block')
|
||||
expect(historyBlock.items).toHaveLength(3)
|
||||
expect(rawMessage.role).toBe('user')
|
||||
expect(rawMessage.content).toBe('continue')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Provider-ready message payload.
|
||||
*
|
||||
* Use when:
|
||||
* - Sending messages to chat-style providers
|
||||
* - Preserving a simple role/content shape alongside richer projected messages
|
||||
*
|
||||
* Expects:
|
||||
* - `content` already serialized into a provider-safe string
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal chat message record that providers can consume directly
|
||||
*/
|
||||
export interface RawMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content: string
|
||||
name?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich message projected from session, spark, or domain data.
|
||||
*
|
||||
* Use when:
|
||||
* - You need structured message segments
|
||||
* - You want to preserve history blocks, summaries, or other contextual payloads
|
||||
*
|
||||
* Expects:
|
||||
* - `segments` to describe the full rendered message content
|
||||
*
|
||||
* Returns:
|
||||
* - A structured message that can be compacted or rendered later
|
||||
*/
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'system' | 'user' | 'assistant' | 'context' | 'event' | 'summary'
|
||||
source?: string
|
||||
segments: MessageSegment[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured content segment used inside a projected message.
|
||||
*/
|
||||
export type MessageSegment
|
||||
= SegmentText
|
||||
| SegmentInstruction
|
||||
| SegmentTaggedText
|
||||
| SegmentDomainEvent
|
||||
| SegmentStateSnapshot
|
||||
| SegmentHistoryBlock
|
||||
| SegmentSummary
|
||||
| SegmentReference
|
||||
|
||||
/**
|
||||
* Plain text segment for projected message rendering.
|
||||
*/
|
||||
export interface SegmentText {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruction segment for explicit runtime or system guidance.
|
||||
*/
|
||||
export interface SegmentInstruction {
|
||||
type: 'instruction'
|
||||
text: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'critical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged text segment that preserves semantic tag boundaries.
|
||||
*/
|
||||
export interface SegmentTaggedText {
|
||||
type: 'tagged-text'
|
||||
tag: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain event segment for structured event payloads.
|
||||
*/
|
||||
export interface SegmentDomainEvent {
|
||||
type: 'domain-event'
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* State snapshot segment for deterministic state serialization.
|
||||
*/
|
||||
export interface SegmentStateSnapshot {
|
||||
type: 'state-snapshot'
|
||||
stateType: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* History block segment that keeps turn/reaction pairing intact.
|
||||
*/
|
||||
export interface SegmentHistoryBlock {
|
||||
type: 'history-block'
|
||||
compacted: boolean
|
||||
items: HistoryItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* History summary item used by a history block segment.
|
||||
*/
|
||||
export interface HistorySummary {
|
||||
type: 'summary'
|
||||
text: string
|
||||
fromTurnIndex?: number
|
||||
toTurnIndex?: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* History reaction item used to keep spark output close to the related turn.
|
||||
*/
|
||||
export interface HistoryReaction {
|
||||
type: 'reaction'
|
||||
reactionType: 'spark-notify' | 'spark-command' | string
|
||||
text: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* History turn item used for structured session or domain turn tracking.
|
||||
*/
|
||||
export interface HistoryTurn {
|
||||
type: 'turn'
|
||||
turnType: string
|
||||
turnIndex: number
|
||||
actor: 'player' | 'assistant' | 'agent' | 'system' | string
|
||||
action: HistoryTurnAction
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured action stored on a turn history item.
|
||||
*/
|
||||
export type HistoryTurnAction
|
||||
= HistoryTurnMoveAction
|
||||
| HistoryTurnTextAction
|
||||
| HistoryTurnEventAction
|
||||
| HistoryTurnGenericAction
|
||||
|
||||
/**
|
||||
* Chess-style move action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnMoveAction {
|
||||
kind: 'move-played' | 'move-executed'
|
||||
san: string
|
||||
uci?: string
|
||||
fen?: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Text action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnTextAction {
|
||||
kind: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Event action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnEventAction {
|
||||
kind: 'event'
|
||||
name: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fallback action stored on a turn.
|
||||
*/
|
||||
export interface HistoryTurnGenericAction {
|
||||
kind: string
|
||||
san?: string
|
||||
uci?: string
|
||||
fen?: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured item stored inside a history block.
|
||||
*/
|
||||
export type HistoryItem
|
||||
= HistorySummary
|
||||
| HistoryReaction
|
||||
| HistoryItemDomainEvent
|
||||
| HistoryTurn
|
||||
|
||||
/**
|
||||
* History domain event item used to preserve structured event provenance.
|
||||
*/
|
||||
export interface HistoryItemDomainEvent {
|
||||
type: 'domain-event'
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for the text segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageTextSegment = SegmentText
|
||||
|
||||
/**
|
||||
* Alias for the instruction segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageInstructionSegment = SegmentInstruction
|
||||
|
||||
/**
|
||||
* Alias for the tagged text segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageTaggedTextSegment = SegmentTaggedText
|
||||
|
||||
/**
|
||||
* Alias for the domain event segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageDomainEventSegment = SegmentDomainEvent
|
||||
|
||||
/**
|
||||
* Alias for the state snapshot segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageStateSnapshotSegment = SegmentStateSnapshot
|
||||
|
||||
/**
|
||||
* Alias for the history block segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageHistoryBlockSegment = SegmentHistoryBlock
|
||||
|
||||
/**
|
||||
* Alias for the summary segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageSummarySegment = SegmentSummary
|
||||
|
||||
/**
|
||||
* Alias for the reference segment shape used by the approved spec.
|
||||
*/
|
||||
export type MessageReferenceSegment = SegmentReference
|
||||
|
||||
/**
|
||||
* Summary segment for historical or narrative windows.
|
||||
*/
|
||||
export interface SegmentSummary {
|
||||
type: 'summary'
|
||||
text: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference segment for stable pointers to prior messages or resources.
|
||||
*/
|
||||
export interface SegmentReference {
|
||||
type: 'reference'
|
||||
refType: string
|
||||
targetId: string
|
||||
note?: string
|
||||
}
|
||||
@@ -113,7 +113,19 @@ export async function streamFrom({
|
||||
|
||||
// NOTICE: Consume underlying promises to prevent unhandled rejections from
|
||||
// @xsai/stream-text's SSE parser surfacing as faulted app state.
|
||||
void streamResult.steps.catch((error) => {
|
||||
// NOTICE:
|
||||
// `streamText(...).steps` is the authoritative completion signal for the
|
||||
// full streamed interaction, including tool-call rounds.
|
||||
// Resolving only from `onEvent({ type: 'finish' })` is incorrect when
|
||||
// `options?.waitForTools === true`, because providers can emit
|
||||
// `finishReason: 'tool_calls'` or `finishReason: 'tool-calls'` before the
|
||||
// tool round has fully settled.
|
||||
// That misuse leaves the outer promise pending, which makes provider-backed
|
||||
// eval tasks look like they stop mid-run and prevents later scheduled evals
|
||||
// from starting.
|
||||
// Keep `steps.then(resolveOnce)` so evaluation runners observe the real end
|
||||
// of the stream lifecycle instead of an intermediate tool boundary.
|
||||
void streamResult.steps.then(resolveOnce).catch((error) => {
|
||||
rejectOnce(error)
|
||||
console.error('Stream steps error:', error)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { defineConfig, loadEnv } from 'vieval'
|
||||
import { chatModelFrom, ChatModels, chatProviderFrom, ChatProviders } from 'vieval/plugins/chat-models'
|
||||
|
||||
const pluginRootDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const loadedEnv = loadEnv('test', pluginRootDirectory, '')
|
||||
const defaultModel = loadedEnv.OPENAI_MODEL ?? loadedEnv.OPENAI_CHAT_MODEL ?? 'openai/gpt-5.4-mini'
|
||||
|
||||
/**
|
||||
* Vieval config for the core-agent runtime competition.
|
||||
*/
|
||||
const coreAgentVievalConfig = defineConfig({
|
||||
plugins: [
|
||||
ChatProviders({
|
||||
providers: [
|
||||
chatProviderFrom({
|
||||
id: 'openrouter-provider',
|
||||
inferenceExecutor: 'openrouter',
|
||||
optionalEnv: {
|
||||
baseURL: 'OPENROUTER_BASE_URL',
|
||||
},
|
||||
requiredEnv: {
|
||||
apiKey: 'OPENROUTER_API_KEY',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
ChatModels({
|
||||
models: [
|
||||
chatModelFrom({
|
||||
aliases: ['default', 'competition'],
|
||||
provider: 'openrouter-provider',
|
||||
model: defaultModel,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
env: loadedEnv,
|
||||
projects: [
|
||||
{
|
||||
name: 'round-3-primary-control',
|
||||
root: '.',
|
||||
include: ['evals/round-3-primary-control/**/*.eval.ts'],
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
runMatrix: {
|
||||
override: {
|
||||
model: [defaultModel],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'round-3-takeover-control',
|
||||
root: '.',
|
||||
include: ['evals/round-3-takeover-control/**/*.eval.ts'],
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
runMatrix: {
|
||||
override: {
|
||||
model: [defaultModel],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'round-3-sidecar-control',
|
||||
root: '.',
|
||||
include: ['evals/round-3-sidecar-control/**/*.eval.ts'],
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
runMatrix: {
|
||||
override: {
|
||||
model: [defaultModel],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default coreAgentVievalConfig
|
||||
@@ -196,4 +196,133 @@ describe('store character-orchestrator', () => {
|
||||
expect(mockOnSparkNotifyReactionStreamEvent).toBeCalledWith(event.data.id, 'Ahhh, got hit by zombie!')
|
||||
expect(mockOnSparkNotifyReactionStreamEnd).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it('supports forcing text-only spark:notify responses', async () => {
|
||||
const mockStream = vi.fn()
|
||||
mockedStore(useLLM).stream = mockStream
|
||||
mockedStore(useLLM).stream.mockImplementation(async (_model: string, _provider: unknown, _messages: unknown, options: any) => {
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'I choose d5 to pressure the center.' } satisfies StreamEvent)
|
||||
await options?.onStreamEvent?.({ type: 'finish' } satisfies StreamEvent)
|
||||
})
|
||||
|
||||
const onDelta = vi.fn()
|
||||
const onEnd = vi.fn()
|
||||
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEvent = onDelta
|
||||
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEnd = onEnd
|
||||
|
||||
const store = useCharacterOrchestratorStore()
|
||||
const event: WebSocketEventOf<'spark:notify'> = {
|
||||
type: 'spark:notify',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
data: {
|
||||
id: nanoid(),
|
||||
eventId: nanoid(),
|
||||
kind: 'ping',
|
||||
urgency: 'immediate',
|
||||
headline: 'AIRI played d5',
|
||||
destinations: ['character'],
|
||||
},
|
||||
}
|
||||
|
||||
await store.handleSparkNotifyWithReaction(event, {
|
||||
forceTextResponse: true,
|
||||
})
|
||||
|
||||
const streamOptions = mockStream.mock.calls[0][3]
|
||||
expect(streamOptions.supportsTools).toBe(false)
|
||||
expect(streamOptions.waitForTools).toBe(false)
|
||||
expect(streamOptions.tools).toEqual([])
|
||||
expect(streamOptions.toolChoice).toBeUndefined()
|
||||
expect(onDelta).toBeCalled()
|
||||
expect(onEnd).toBeCalled()
|
||||
})
|
||||
|
||||
it('supports forcing spark-command responses', async () => {
|
||||
const mockStream = vi.fn()
|
||||
mockedStore(useLLM).stream = mockStream
|
||||
mockedStore(useLLM).stream.mockImplementation(async (_model: string, _provider: unknown, _messages: unknown, options: any) => {
|
||||
const sparkCommandTool = options?.tools?.find((tool: any) => tool.function?.name === 'builtIn_sparkCommand')
|
||||
await sparkCommandTool.execute({
|
||||
commands: [{
|
||||
destinations: ['minecraft'],
|
||||
intent: 'action',
|
||||
priority: 'high',
|
||||
interrupt: 'false',
|
||||
ack: 'go',
|
||||
guidance: null,
|
||||
}],
|
||||
} satisfies z.infer<typeof sparkNotifyCommandSchema>)
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'This should be ignored.' } satisfies StreamEvent)
|
||||
await options?.onStreamEvent?.({ type: 'finish' } satisfies StreamEvent)
|
||||
})
|
||||
|
||||
const onDelta = vi.fn()
|
||||
const onEnd = vi.fn()
|
||||
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEvent = onDelta
|
||||
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEnd = onEnd
|
||||
|
||||
const store = useCharacterOrchestratorStore()
|
||||
const event: WebSocketEventOf<'spark:notify'> = {
|
||||
type: 'spark:notify',
|
||||
source: 'minecraft',
|
||||
data: {
|
||||
id: nanoid(),
|
||||
eventId: nanoid(),
|
||||
kind: 'alarm',
|
||||
urgency: 'immediate',
|
||||
headline: 'Take cover',
|
||||
destinations: ['character'],
|
||||
},
|
||||
}
|
||||
|
||||
const result = await store.handleSparkNotify(event, {
|
||||
forceSparkCommandResponse: true,
|
||||
})
|
||||
|
||||
const streamOptions = mockStream.mock.calls[0][3]
|
||||
expect(streamOptions.supportsTools).toBe(true)
|
||||
expect(streamOptions.waitForTools).toBe(true)
|
||||
expect(streamOptions.toolChoice).toEqual({
|
||||
type: 'function',
|
||||
function: { name: 'builtIn_sparkCommand' },
|
||||
})
|
||||
expect(result?.commands?.length).toBe(1)
|
||||
expect(onDelta).not.toBeCalled()
|
||||
expect(onEnd).toBeCalledWith(event.data.id, '')
|
||||
})
|
||||
|
||||
it('forwards runtime-only message overrides into the rendered spark prompt', async () => {
|
||||
const mockStream = vi.fn()
|
||||
mockedStore(useLLM).stream = mockStream
|
||||
mockedStore(useLLM).stream.mockImplementation(async (_model: string, _provider: unknown, _messages: unknown, options: any) => {
|
||||
await options?.onStreamEvent?.({ type: 'text-delta', text: 'legacy-safe text' } satisfies StreamEvent)
|
||||
await options?.onStreamEvent?.({ type: 'finish' } satisfies StreamEvent)
|
||||
})
|
||||
|
||||
const store = useCharacterOrchestratorStore()
|
||||
const event: WebSocketEventOf<'spark:notify'> = {
|
||||
type: 'spark:notify',
|
||||
source: 'plugin:airi-plugin-game-chess',
|
||||
data: {
|
||||
id: nanoid(),
|
||||
eventId: nanoid(),
|
||||
kind: 'ping',
|
||||
urgency: 'immediate',
|
||||
headline: 'Legacy rendering',
|
||||
destinations: ['character'],
|
||||
},
|
||||
}
|
||||
|
||||
await store.handleSparkNotify(event, {
|
||||
forceTextResponse: true,
|
||||
messageOverride: {
|
||||
appendSystemInstructions: ['Plugin-specific hint'],
|
||||
appendUserSections: ['Rendered board snapshot'],
|
||||
},
|
||||
})
|
||||
|
||||
const renderedMessages = mockStream.mock.calls[0]?.[2] as Array<{ role: string, content: string }> | undefined
|
||||
expect(String(renderedMessages?.[0]?.content)).toContain('Plugin-specific hint')
|
||||
expect(String(renderedMessages?.[1]?.content)).toContain('Rendered board snapshot')
|
||||
})
|
||||
})
|
||||
|
||||
+9
@@ -1,10 +1,19 @@
|
||||
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,5 +1,7 @@
|
||||
import type { SparkNotifyResponseControl } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import type { WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
|
||||
|
||||
import { setupAgentSparkNotifyHandler } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -8,9 +10,8 @@ import { useLLM } from '../../llm'
|
||||
import { useModsServerChannelStore } from '../../mods/api/channel-server'
|
||||
import { useConsciousnessStore } from '../../modules/consciousness'
|
||||
import { useProvidersStore } from '../../providers'
|
||||
import { setupAgentSparkNotifyHandler } from './agents/event-handler-spark-notify'
|
||||
|
||||
export { sparkNotifyCommandSchema } from './agents/event-handler-spark-notify'
|
||||
export { sparkNotifyCommandSchema } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
|
||||
export const useCharacterOrchestratorStore = defineStore('character-orchestrator', () => {
|
||||
const { stream } = useLLM()
|
||||
@@ -25,6 +26,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
const pendingNotifies = ref<Array<WebSocketEventOf<'spark:notify'>>>([])
|
||||
const scheduledNotifies = ref<Array<{
|
||||
event: WebSocketEventOf<'spark:notify'>
|
||||
control?: SparkNotifyResponseControl
|
||||
enqueuedAt: number
|
||||
nextRunAt: number
|
||||
attempts: number
|
||||
@@ -76,13 +78,22 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
pendingNotifies.value = pendingNotifies.value.filter(item => item.data.id !== eventId)
|
||||
}
|
||||
|
||||
function enqueueSparkNotify(event: WebSocketEventOf<'spark:notify'>, options?: { reason?: string, nextRunAt?: number, maxAttempts?: number }) {
|
||||
function enqueueSparkNotify(
|
||||
event: WebSocketEventOf<'spark:notify'>,
|
||||
options?: {
|
||||
reason?: string
|
||||
nextRunAt?: number
|
||||
maxAttempts?: number
|
||||
control?: SparkNotifyResponseControl
|
||||
},
|
||||
) {
|
||||
if (!pendingNotifies.value.some(item => item.data.id === event.data.id)) {
|
||||
pendingNotifies.value.push(event)
|
||||
}
|
||||
|
||||
scheduledNotifies.value.push({
|
||||
event,
|
||||
control: options?.control,
|
||||
enqueuedAt: Date.now(),
|
||||
nextRunAt: options?.nextRunAt ?? computeNextRunAt(event, 0),
|
||||
attempts: 0,
|
||||
@@ -91,8 +102,8 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
})
|
||||
}
|
||||
|
||||
async function processSparkNotify(event: WebSocketEventOf<'spark:notify'>) {
|
||||
const result = await sparkNotifyAgent.handle(event)
|
||||
async function processSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
|
||||
const result = await sparkNotifyAgent.handle(event, control)
|
||||
if (!result?.commands?.length)
|
||||
return result
|
||||
|
||||
@@ -106,15 +117,30 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleIncomingSparkNotify(event: WebSocketEventOf<'spark:notify'>) {
|
||||
async function handleIncomingSparkNotify(event: WebSocketEventOf<'spark:notify'>, control?: SparkNotifyResponseControl) {
|
||||
if (event.data.urgency === 'immediate' && !processing.value) {
|
||||
return await processSparkNotify(event)
|
||||
return await processSparkNotify(event, control)
|
||||
}
|
||||
|
||||
enqueueSparkNotify(event, { reason: 'spark:notify' })
|
||||
enqueueSparkNotify(event, { reason: 'spark:notify', control })
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function handleSparkNotifyWithReaction(
|
||||
event: WebSocketEventOf<'spark:notify'>,
|
||||
options?: SparkNotifyResponseControl & { fallbackText?: string },
|
||||
) {
|
||||
await handleIncomingSparkNotify(event, options)
|
||||
|
||||
const reaction = [...characterStore.reactions]
|
||||
.reverse()
|
||||
.find(item => item.sourceEventId === event.data.id)
|
||||
?.message
|
||||
?.trim()
|
||||
|
||||
return reaction || options?.fallbackText || ''
|
||||
}
|
||||
|
||||
function enqueueDueTasks(now: number) {
|
||||
const dueTasks = notebookStore.getDueTasks(now, attentionConfig.value.taskNotifyWindowMs)
|
||||
if (!dueTasks.length)
|
||||
@@ -160,7 +186,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
removePending(next.event.data.id)
|
||||
|
||||
try {
|
||||
await processSparkNotify(next.event)
|
||||
await processSparkNotify(next.event, next.control)
|
||||
}
|
||||
catch (error) {
|
||||
if (next.attempts + 1 < next.maxAttempts) {
|
||||
@@ -253,6 +279,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
dispose,
|
||||
|
||||
handleSparkNotify: handleIncomingSparkNotify,
|
||||
handleSparkNotifyWithReaction,
|
||||
handleSparkEmit,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ChatHistoryItem } from '../../types/chat'
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mergeLoadedSessionMessages } from './session-message-merge'
|
||||
|
||||
@@ -16,7 +14,7 @@ describe('mergeLoadedSessionMessages', () => {
|
||||
{ role: 'system', content: 'system', createdAt: 3, id: 'system-current' },
|
||||
]
|
||||
|
||||
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
|
||||
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toBe(storedMessages)
|
||||
})
|
||||
|
||||
it('appends in-flight messages when IndexedDB finishes loading after a new send starts', () => {
|
||||
@@ -29,7 +27,7 @@ describe('mergeLoadedSessionMessages', () => {
|
||||
{ role: 'user', content: 'latest prompt', createdAt: 4, id: 'user-2' },
|
||||
]
|
||||
|
||||
assert.deepEqual(mergeLoadedSessionMessages(storedMessages, currentMessages), [
|
||||
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toEqual([
|
||||
...storedMessages,
|
||||
currentMessages[1],
|
||||
])
|
||||
@@ -45,7 +43,7 @@ describe('mergeLoadedSessionMessages', () => {
|
||||
{ role: 'user', content: 'latest prompt', createdAt: 4 },
|
||||
]
|
||||
|
||||
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
|
||||
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toBe(storedMessages)
|
||||
})
|
||||
|
||||
it('keeps a system message when storage is empty and current has in-flight user messages', () => {
|
||||
@@ -55,7 +53,7 @@ describe('mergeLoadedSessionMessages', () => {
|
||||
{ role: 'user', content: 'in-flight prompt', createdAt: 2, id: 'user-1' },
|
||||
]
|
||||
|
||||
assert.deepEqual(mergeLoadedSessionMessages(storedMessages, currentMessages), [
|
||||
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toEqual([
|
||||
currentMessages[0],
|
||||
currentMessages[1],
|
||||
])
|
||||
@@ -85,6 +83,6 @@ describe('mergeLoadedSessionMessages', () => {
|
||||
},
|
||||
]
|
||||
|
||||
assert.equal(mergeLoadedSessionMessages(storedMessages, currentMessages), storedMessages)
|
||||
expect(mergeLoadedSessionMessages(storedMessages, currentMessages)).toBe(storedMessages)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SparkNotifyMessageOverride } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { UserMessage } from '@xsai/shared-chat'
|
||||
|
||||
@@ -11,6 +13,7 @@ import { defineStore, storeToRefs } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { getEventSourceKey } from '../../../utils/event-source'
|
||||
import { useCharacterOrchestratorStore } from '../../character'
|
||||
import { useChatOrchestratorStore } from '../../chat'
|
||||
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME } from '../../chat/constants'
|
||||
import { useChatContextStore } from '../../chat/context-store'
|
||||
@@ -49,22 +52,125 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
const chatContext = useChatContextStore()
|
||||
const serverChannelStore = useModsServerChannelStore()
|
||||
const contextObservability = useContextObservabilityStore()
|
||||
const characterOrchestratorStore = useCharacterOrchestratorStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { activeProvider, activeModel } = storeToRefs(consciousnessStore)
|
||||
|
||||
const { post: broadcastContext, data: incomingContext } = useBroadcastChannel<ContextMessage, ContextMessage>({ name: CONTEXT_CHANNEL_NAME })
|
||||
const { post: broadcastStreamEvent, data: incomingStreamEvent } = useBroadcastChannel<ChatStreamEvent, ChatStreamEvent>({ name: CHAT_STREAM_CHANNEL_NAME })
|
||||
interface SparkNotifyReactionOptions {
|
||||
headline: string
|
||||
fallbackText: string
|
||||
note?: string
|
||||
payload?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
lane?: string
|
||||
kind?: 'alarm' | 'ping' | 'reminder'
|
||||
urgency?: 'immediate' | 'soon' | 'later'
|
||||
destinations?: string[]
|
||||
source?: string
|
||||
ttlMs?: number
|
||||
requiresAck?: boolean
|
||||
forceResponse?: boolean
|
||||
forceTextResponse?: boolean
|
||||
forceSparkCommandResponse?: boolean
|
||||
messageOverride?: SparkNotifyMessageOverride
|
||||
}
|
||||
type SparkNotifyBridgeMessage
|
||||
= | {
|
||||
type: 'request'
|
||||
requestId: string
|
||||
fromInstanceId: string
|
||||
payload: SparkNotifyReactionOptions
|
||||
}
|
||||
| {
|
||||
type: 'response'
|
||||
requestId: string
|
||||
toInstanceId: string
|
||||
reaction: string
|
||||
}
|
||||
const SPARK_NOTIFY_BRIDGE_CHANNEL_NAME = 'airi-spark-notify-bridge'
|
||||
const sparkNotifyBridgeInstanceId = `spark-notify-${nanoid()}`
|
||||
const sparkNotifyHostRole = ref<'main' | 'client'>('client')
|
||||
const sparkNotifyBridgeWaiters = new Map<string, {
|
||||
resolve: (reaction: string) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}>()
|
||||
const { post: postSparkNotifyBridgeMessage, data: incomingSparkNotifyBridgeMessage } = useBroadcastChannel<SparkNotifyBridgeMessage, SparkNotifyBridgeMessage>({ name: SPARK_NOTIFY_BRIDGE_CHANNEL_NAME })
|
||||
|
||||
const disposeHookFns = ref<Array<() => void>>([])
|
||||
let remoteStreamGuard: { sessionId: string, generation: number } | null = null
|
||||
let initialized = false
|
||||
|
||||
async function handleSparkNotifyReactionLocal(options: SparkNotifyReactionOptions) {
|
||||
const event: WebSocketEventOf<'spark:notify'> = {
|
||||
type: 'spark:notify',
|
||||
source: options.source ?? 'plugin-module-host',
|
||||
data: {
|
||||
id: nanoid(),
|
||||
eventId: nanoid(),
|
||||
lane: options.lane,
|
||||
kind: options.kind ?? 'ping',
|
||||
urgency: options.urgency ?? 'immediate',
|
||||
headline: options.headline,
|
||||
note: options.note,
|
||||
payload: options.payload,
|
||||
ttlMs: options.ttlMs,
|
||||
requiresAck: options.requiresAck,
|
||||
destinations: options.destinations?.length ? options.destinations : ['character'],
|
||||
metadata: options.metadata,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
return await characterOrchestratorStore.handleSparkNotifyWithReaction(event, {
|
||||
fallbackText: options.fallbackText,
|
||||
forceResponse: options.forceResponse,
|
||||
forceTextResponse: options.forceTextResponse,
|
||||
forceSparkCommandResponse: options.forceSparkCommandResponse,
|
||||
messageOverride: options.messageOverride,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[context-bridge] spark:notify handling failed; using fallback', error)
|
||||
return options.fallbackText
|
||||
}
|
||||
}
|
||||
|
||||
function setSparkNotifyHostRole(role: 'main' | 'client') {
|
||||
sparkNotifyHostRole.value = role
|
||||
}
|
||||
|
||||
async function dispatchSparkNotifyReaction(options: SparkNotifyReactionOptions) {
|
||||
if (sparkNotifyHostRole.value === 'main') {
|
||||
return await handleSparkNotifyReactionLocal(options)
|
||||
}
|
||||
|
||||
const requestId = nanoid()
|
||||
return await new Promise<string>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sparkNotifyBridgeWaiters.delete(requestId)
|
||||
resolve(options.fallbackText)
|
||||
}, 5000)
|
||||
|
||||
sparkNotifyBridgeWaiters.set(requestId, {
|
||||
resolve: (reaction) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(reaction || options.fallbackText)
|
||||
},
|
||||
timeout,
|
||||
})
|
||||
|
||||
postSparkNotifyBridgeMessage({
|
||||
type: 'request',
|
||||
requestId,
|
||||
fromInstanceId: sparkNotifyBridgeInstanceId,
|
||||
payload: options,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await mutex.acquire()
|
||||
|
||||
@@ -130,6 +236,42 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
})
|
||||
disposeHookFns.value.push(stop)
|
||||
|
||||
const { stop: stopSparkNotifyBridgeWatch } = watch(incomingSparkNotifyBridgeMessage, async (event) => {
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'request') {
|
||||
if (sparkNotifyHostRole.value !== 'main' || event.fromInstanceId === sparkNotifyBridgeInstanceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const reaction = await handleSparkNotifyReactionLocal(event.payload)
|
||||
postSparkNotifyBridgeMessage({
|
||||
type: 'response',
|
||||
requestId: event.requestId,
|
||||
toInstanceId: event.fromInstanceId,
|
||||
reaction,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'response') {
|
||||
if (event.toInstanceId !== sparkNotifyBridgeInstanceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const waiter = sparkNotifyBridgeWaiters.get(event.requestId)
|
||||
if (!waiter) {
|
||||
return
|
||||
}
|
||||
|
||||
sparkNotifyBridgeWaiters.delete(event.requestId)
|
||||
waiter.resolve(event.reaction)
|
||||
}
|
||||
})
|
||||
disposeHookFns.value.push(stopSparkNotifyBridgeWatch)
|
||||
|
||||
disposeHookFns.value.push(serverChannelStore.onContextUpdate((event) => {
|
||||
contextObservability.recordLifecycle({
|
||||
phase: 'server-received',
|
||||
@@ -519,6 +661,11 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
|
||||
initialized = false
|
||||
remoteStreamGuard = null
|
||||
|
||||
for (const [requestId, waiter] of sparkNotifyBridgeWaiters) {
|
||||
clearTimeout(waiter.timeout)
|
||||
sparkNotifyBridgeWaiters.delete(requestId)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
mutex.release()
|
||||
@@ -530,6 +677,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
return {
|
||||
initialize,
|
||||
dispose,
|
||||
dispatchSparkNotifyReaction,
|
||||
setSparkNotifyHostRole,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export type {
|
||||
CreateSparkNotifyToolsOptions,
|
||||
SparkNotifyCommandDraft,
|
||||
SparkNotifyCommandSchema,
|
||||
} from '@proj-airi/core-agent/agents/spark-notify'
|
||||
|
||||
export {
|
||||
createSparkNotifyTools,
|
||||
sparkNotifyCommandSchema,
|
||||
|
||||
Reference in New Issue
Block a user