refactor(core-agent,stage-ui): move spark-notify agent to core-agent
This commit is contained in:
@@ -19,6 +19,10 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./agents/spark-notify": {
|
||||
"types": "./dist/agents/spark-notify/index.d.mts",
|
||||
"default": "./dist/agents/spark-notify/index.mjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -33,11 +37,17 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moeru/std": "catalog:",
|
||||
"@proj-airi/server-sdk": "workspace:^",
|
||||
"@proj-airi/server-shared": "workspace:^",
|
||||
"@xsai-ext/providers": "catalog:",
|
||||
"@xsai/model": "catalog:",
|
||||
"@xsai/shared-chat": "catalog:",
|
||||
"@xsai/stream-text": "catalog:"
|
||||
"@xsai/stream-text": "catalog:",
|
||||
"@xsai/tool": "catalog:",
|
||||
"nanoid": "^5.1.7",
|
||||
"xsschema": "catalog:",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "catalog:",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MetadataEventSource } from '@proj-airi/server-sdk'
|
||||
|
||||
interface EventSourcePayload {
|
||||
source?: string
|
||||
metadata?: { source?: MetadataEventSource }
|
||||
}
|
||||
|
||||
function formatMetadataSource(source?: MetadataEventSource) {
|
||||
if (!source?.plugin)
|
||||
return undefined
|
||||
|
||||
const pluginId = source.plugin.id
|
||||
const instanceId = source.id
|
||||
|
||||
return instanceId ? `${pluginId}:${instanceId}` : pluginId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a stable source key for websocket-originated events.
|
||||
*
|
||||
* Before:
|
||||
* - `{ source: "minecraft" }`
|
||||
* - `{ metadata: { source: { plugin: { id: "p" }, id: "i" } } }`
|
||||
*
|
||||
* After:
|
||||
* - `"minecraft"`
|
||||
* - `"p:i"`
|
||||
*/
|
||||
export function getEventSourceKey(event: EventSourcePayload, fallback = 'unknown') {
|
||||
return (
|
||||
formatMetadataSource(event.metadata?.source)
|
||||
?? event.source
|
||||
?? fallback
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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 { StreamEvent } from '../../types/llm'
|
||||
import type { SparkNotifyCommandDraft } from './tools'
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
import { getEventSourceKey } from './event-source'
|
||||
import { createSparkNotifyTools } from './tools'
|
||||
|
||||
export interface SparkNotifyResponse {
|
||||
reaction?: string
|
||||
commands?: SparkNotifyCommandDraft[]
|
||||
}
|
||||
|
||||
export interface SparkNotifyCommandEvent {
|
||||
id: string
|
||||
eventId: string
|
||||
parentEventId: string
|
||||
commandId: string
|
||||
interrupt: 'force' | 'soft' | false
|
||||
priority: 'critical' | 'high' | 'normal' | 'low'
|
||||
intent: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
|
||||
ack?: string
|
||||
guidance?: SparkNotifyCommandDraft['guidance']
|
||||
contexts?: SparkNotifyCommandDraft['contexts']
|
||||
destinations: string[]
|
||||
}
|
||||
|
||||
export interface SparkNotifyHandleResult {
|
||||
commands: SparkNotifyCommandEvent[]
|
||||
}
|
||||
|
||||
export interface SparkNotifyAgentDeps {
|
||||
stream: (
|
||||
model: string,
|
||||
provider: ChatProvider,
|
||||
messages: Message[],
|
||||
options: {
|
||||
tools?: any[]
|
||||
supportsTools?: boolean
|
||||
waitForTools?: boolean
|
||||
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
|
||||
},
|
||||
) => Promise<void>
|
||||
getActiveProvider: () => string | undefined
|
||||
getActiveModel: () => string | undefined
|
||||
getProviderInstance: <R extends
|
||||
| ChatProvider
|
||||
| ChatProviderWithExtraOptions
|
||||
| EmbedProvider
|
||||
| EmbedProviderWithExtraOptions
|
||||
| SpeechProvider
|
||||
| SpeechProviderWithExtraOptions
|
||||
| TranscriptionProvider
|
||||
| TranscriptionProviderWithExtraOptions,
|
||||
>(name: string,
|
||||
) => Promise<R>
|
||||
onReactionDelta: (eventId: string, text: string) => void
|
||||
onReactionEnd: (eventId: string, text: string) => void
|
||||
getSystemPrompt: () => string
|
||||
getProcessing: () => boolean
|
||||
setProcessing: (next: boolean) => void
|
||||
getPending: () => Array<WebSocketEventOf<'spark:notify'>>
|
||||
setPending: (next: Array<WebSocketEventOf<'spark:notify'>>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the instruction block prepended to Spark Notify agent prompts.
|
||||
*
|
||||
* Use when:
|
||||
* - Handling `spark:notify` events
|
||||
* - Constructing the per-turn system instruction for the notify reaction agent
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleName` resolved from event source metadata
|
||||
*
|
||||
* Returns:
|
||||
* - Multiline instruction text for system prompt composition
|
||||
*/
|
||||
export function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
return [
|
||||
'This is AIRI system, the life pod hosting your consciousness. You don\'t need to respond to me or every spark:notify event directly.',
|
||||
`Another module "${moduleName}" triggered spark:notify event for you to checkout.`,
|
||||
'You may call the built-in tool "builtIn_sparkCommand" to issue spark:command to sub-agents as needed.',
|
||||
'For any of the output that is not a tool call, it will be streamed to user\'s interface and maybe processed with text to speech system ',
|
||||
'to be played out loud as your actual reaction to the spark:notify event.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - 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
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* `handle`
|
||||
* -> `runNotifyAgent`
|
||||
* -> `createSparkNotifyTools`
|
||||
* -> `deps.stream`
|
||||
* -> `deps.onReactionDelta`/`deps.onReactionEnd`
|
||||
*/
|
||||
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps): {
|
||||
handle: (event: WebSocketEventOf<'spark:notify'>) => Promise<SparkNotifyHandleResult | undefined>
|
||||
} {
|
||||
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
|
||||
const activeProvider = deps.getActiveProvider()
|
||||
const activeModel = deps.getActiveModel()
|
||||
if (!activeProvider || !activeModel) {
|
||||
console.warn('Spark notify ignored: missing active provider or model')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const chatProvider = await deps.getProviderInstance<ChatProvider>(activeProvider)
|
||||
const commandDrafts: SparkNotifyCommandDraft[] = []
|
||||
|
||||
let noResponse = false
|
||||
|
||||
const { tools } = await createSparkNotifyTools({
|
||||
onNoResponse: () => {
|
||||
noResponse = true
|
||||
},
|
||||
onCommands: commands => commandDrafts.push(...commands),
|
||||
})
|
||||
|
||||
const systemMessage: Message = {
|
||||
role: 'system',
|
||||
content: [
|
||||
deps.getSystemPrompt(),
|
||||
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
|
||||
].filter(Boolean).join('\n\n'),
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
notify: event.data,
|
||||
source: event.source,
|
||||
}, null, 2),
|
||||
}
|
||||
|
||||
let fullText = ''
|
||||
|
||||
await deps.stream(activeModel, chatProvider, [systemMessage, userMessage], {
|
||||
tools,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
onStreamEvent: async (streamEvent: StreamEvent) => {
|
||||
if (streamEvent.type === 'text-delta') {
|
||||
if (noResponse)
|
||||
return
|
||||
|
||||
deps.onReactionDelta(event.data.id, streamEvent.text)
|
||||
|
||||
fullText += streamEvent.text
|
||||
}
|
||||
if (streamEvent.type === 'finish') {
|
||||
if (noResponse) {
|
||||
deps.onReactionEnd(event.data.id, '')
|
||||
return
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
reaction: fullText.trim(),
|
||||
commands: commandDrafts,
|
||||
} satisfies SparkNotifyResponse
|
||||
}
|
||||
|
||||
async function handle(event: WebSocketEventOf<'spark:notify'>): Promise<SparkNotifyHandleResult | undefined> {
|
||||
if (event.data.urgency !== 'immediate' && deps.getPending().length > 0) {
|
||||
deps.setPending([...deps.getPending(), event])
|
||||
return undefined
|
||||
}
|
||||
if (deps.getProcessing()) {
|
||||
deps.setPending([...deps.getPending(), event])
|
||||
return undefined
|
||||
}
|
||||
|
||||
deps.setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await runNotifyAgent(event)
|
||||
if (!response)
|
||||
return undefined
|
||||
|
||||
const commands = (response.commands ?? [])
|
||||
.map(command => ({
|
||||
id: nanoid(),
|
||||
eventId: nanoid(),
|
||||
parentEventId: event.data.id,
|
||||
commandId: nanoid(),
|
||||
interrupt: (command.interrupt === true ? 'force' : command.interrupt) ?? false,
|
||||
priority: command.priority ?? 'normal',
|
||||
intent: command.intent ?? 'action',
|
||||
ack: command.ack,
|
||||
guidance: command.guidance,
|
||||
contexts: command.contexts,
|
||||
destinations: command.destinations ?? [],
|
||||
} satisfies SparkNotifyCommandEvent))
|
||||
.filter(command => command.destinations.length > 0)
|
||||
|
||||
return {
|
||||
commands,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
deps.setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type {
|
||||
SparkNotifyAgentDeps,
|
||||
SparkNotifyCommandEvent,
|
||||
SparkNotifyHandleResult,
|
||||
SparkNotifyResponse,
|
||||
} from './handler'
|
||||
export {
|
||||
getSparkNotifyHandlingAgentInstruction,
|
||||
setupAgentSparkNotifyHandler,
|
||||
} from './handler'
|
||||
export type { SparkNotifyCommandSchema } from './schema'
|
||||
export {
|
||||
sparkNotifyCommandItemSchema,
|
||||
sparkNotifyCommandSchema,
|
||||
} from './schema'
|
||||
export type {
|
||||
CreateSparkNotifyToolsOptions,
|
||||
SparkNotifyCommandDraft,
|
||||
} from './tools'
|
||||
export { createSparkNotifyTools } from './tools'
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { JsonSchema } from 'xsschema'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const JSON_SCHEMA_NULLABLE_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
|
||||
|
||||
function isJsonSchema(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema {
|
||||
return Boolean(value && !Array.isArray(value) && typeof value === 'object')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes nullable scalar unions in generated JSON schema.
|
||||
*
|
||||
* Before:
|
||||
* - `{ anyOf: [{ type: 'string' }, { type: 'null' }] }`
|
||||
*
|
||||
* After:
|
||||
* - `{ type: ['string', 'null'] }`
|
||||
*/
|
||||
export function normalizeNullableAnyOf(schema: JsonSchema): JsonSchema {
|
||||
// NOTICE: `xsschema` emits nullable unions using `anyOf`, but some OpenAI-compatible
|
||||
// validators reject that shape while accepting `type: ['string', 'null']`.
|
||||
const next: JsonSchema = { ...schema }
|
||||
|
||||
if (next.properties) {
|
||||
next.properties = Object.fromEntries(
|
||||
Object.entries(next.properties).map(([key, value]) => {
|
||||
if (!isJsonSchema(value))
|
||||
return [key, value]
|
||||
return [key, normalizeNullableAnyOf(value)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(next.items)) {
|
||||
next.items = next.items.map(item => isJsonSchema(item) ? normalizeNullableAnyOf(item) : item)
|
||||
}
|
||||
else if (isJsonSchema(next.items)) {
|
||||
next.items = normalizeNullableAnyOf(next.items)
|
||||
}
|
||||
|
||||
if (next.anyOf) {
|
||||
next.anyOf = next.anyOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
|
||||
|
||||
const normalizedEntries = next.anyOf.filter(isJsonSchema)
|
||||
const primitiveTypes = normalizedEntries
|
||||
.map(entry => entry.type)
|
||||
.filter((type): type is Exclude<JsonSchema['type'], JsonSchema['type'][]> => typeof type === 'string')
|
||||
const dedupedPrimitiveTypes = [...new Set(primitiveTypes)]
|
||||
|
||||
if (
|
||||
primitiveTypes.length === normalizedEntries.length
|
||||
&& dedupedPrimitiveTypes.length > 0
|
||||
&& dedupedPrimitiveTypes.every(type => JSON_SCHEMA_NULLABLE_SCALAR_TYPES.has(String(type)))
|
||||
) {
|
||||
delete next.anyOf
|
||||
next.type = dedupedPrimitiveTypes as JsonSchema['type']
|
||||
}
|
||||
}
|
||||
|
||||
if (next.oneOf) {
|
||||
next.oneOf = next.oneOf.map(value => isJsonSchema(value) ? normalizeNullableAnyOf(value) : value)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export const sparkCommandGuidanceOptionSchema = z.object({
|
||||
label: z.string().describe('Short label for the option.'),
|
||||
steps: z.array(z.string()).min(1).describe('Step-by-step actions the target should follow.'),
|
||||
rationale: z.union([z.string(), z.null()]).describe('Why this option makes sense.'),
|
||||
possibleOutcome: z.union([z.array(z.string()), z.null()]).describe('Expected outcomes if this option is followed.'),
|
||||
risk: z.union([z.enum(['high', 'medium', 'low', 'none']), z.null()]).describe('Risk level of this option.'),
|
||||
fallback: z.union([z.array(z.string()), z.null()]).describe('Fallback steps if the main plan fails.'),
|
||||
triggers: z.union([z.array(z.string()), z.null()]).describe('Conditions that should trigger this option.'),
|
||||
}).strict()
|
||||
|
||||
export const sparkCommandPersonaSchema = z.object({
|
||||
traits: z.string().describe('Trait name to adjust behavior. For example, "bravery", "cautiousness", "friendliness".'),
|
||||
strength: z.enum(['very-high', 'high', 'medium', 'low', 'very-low']),
|
||||
}).strict()
|
||||
|
||||
export const sparkNotifyCommandGuidanceSchema = z.object({
|
||||
type: z.enum(['proposal', 'instruction', 'memory-recall']),
|
||||
persona: z.union([z.array(sparkCommandPersonaSchema), z.null()]).describe('Optional persona controls for the receiver.'),
|
||||
options: z.array(sparkCommandGuidanceOptionSchema),
|
||||
}).strict()
|
||||
|
||||
export const sparkNotifyCommandItemSchema = z.object({
|
||||
destinations: z.array(z.string()).min(1).describe('List of sub-agent IDs to send the command to'),
|
||||
interrupt: z.union([z.enum(['force', 'soft', 'false']), z.null()]).describe('Interrupt type: force, soft, or false (no interrupt).'),
|
||||
priority: z.union([z.enum(['critical', 'high', 'normal', 'low']), z.null()]).describe('Semantic priority of the command.'),
|
||||
intent: z.union([z.enum(['plan', 'proposal', 'action', 'pause', 'resume', 'reroute', 'context']), z.null()]).describe('Intent of the command.'),
|
||||
ack: z.string().describe('Acknowledgment content used to be passed to sub-agents upon command receipt.'),
|
||||
guidance: z.union([sparkNotifyCommandGuidanceSchema, z.null()]).describe('Guidance for the sub-agent on how to interpret and execute the command.'),
|
||||
}).strict()
|
||||
|
||||
export const sparkNotifyCommandSchema = z.object({
|
||||
commands: z.array(sparkNotifyCommandItemSchema).describe('List of commands to issue to sub-agents. Empty array can be used for zero commands.'),
|
||||
}).strict()
|
||||
|
||||
export type SparkNotifyCommandSchema = z.infer<typeof sparkNotifyCommandSchema>
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { ContextUpdate } from '@proj-airi/server-sdk'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { rawTool } from '@xsai/tool'
|
||||
import { toJsonSchema, validate } from 'xsschema'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
normalizeNullableAnyOf,
|
||||
sparkNotifyCommandSchema,
|
||||
} from './schema'
|
||||
|
||||
export interface SparkNotifyCommandDraft {
|
||||
destinations: string[]
|
||||
interrupt?: 'force' | 'soft' | boolean
|
||||
priority?: 'critical' | 'high' | 'normal' | 'low'
|
||||
intent?: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
|
||||
ack?: string
|
||||
guidance?: {
|
||||
type: 'proposal' | 'instruction' | 'memory-recall'
|
||||
persona?: Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>
|
||||
options: Array<{
|
||||
label: string
|
||||
steps: string[]
|
||||
rationale?: string
|
||||
possibleOutcome?: string[]
|
||||
risk?: 'high' | 'medium' | 'low' | 'none'
|
||||
fallback?: string[]
|
||||
triggers?: string[]
|
||||
}>
|
||||
}
|
||||
contexts?: ContextUpdate<Record<string, unknown>, undefined>[]
|
||||
}
|
||||
|
||||
export interface CreateSparkNotifyToolsOptions {
|
||||
onCommands: (commands: SparkNotifyCommandDraft[]) => void
|
||||
onNoResponse: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes provider-facing notify command payloads into websocket draft commands.
|
||||
*
|
||||
* Use when:
|
||||
* - LLM output has been validated against `sparkNotifyCommandSchema`
|
||||
* - You need `spark:command`-compatible draft objects
|
||||
*
|
||||
* Expects:
|
||||
* - Input shape from one `commands[]` entry
|
||||
*
|
||||
* Returns:
|
||||
* - Runtime-ready command draft for downstream emitters
|
||||
*/
|
||||
function normalizeSparkNotifyCommand(
|
||||
command: z.infer<typeof sparkNotifyCommandSchema>['commands'][number],
|
||||
): SparkNotifyCommandDraft {
|
||||
return {
|
||||
destinations: command.destinations,
|
||||
guidance: command.guidance
|
||||
? {
|
||||
type: command.guidance.type,
|
||||
persona: command.guidance.persona?.reduce((acc, curr) => {
|
||||
acc[curr.traits] = curr.strength
|
||||
return acc
|
||||
}, {} as Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>) || undefined,
|
||||
options: command.guidance.options.map(option => ({
|
||||
...option,
|
||||
rationale: option.rationale ?? undefined,
|
||||
possibleOutcome: option.possibleOutcome?.length ? option.possibleOutcome : undefined,
|
||||
risk: option.risk ?? undefined,
|
||||
fallback: option.fallback?.length ? option.fallback : undefined,
|
||||
triggers: option.triggers?.length ? option.triggers : undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
// TODO: contexts can be added later
|
||||
contexts: [],
|
||||
priority: command.priority || 'normal',
|
||||
intent: command.intent || 'action',
|
||||
ack: command.ack || undefined,
|
||||
interrupt: command.interrupt === 'false' || command.interrupt == null ? false : command.interrupt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates built-in tools used by the Spark Notify agent.
|
||||
*
|
||||
* Use when:
|
||||
* - Running the Spark Notify agent on any runtime (web, desktop, eval harness)
|
||||
* - You need "no response" and "command draft" tool pathways
|
||||
*
|
||||
* Expects:
|
||||
* - Callbacks for command collection and no-response signaling
|
||||
*
|
||||
* Returns:
|
||||
* - Tool array consumable by `@xsai/stream-text`
|
||||
*/
|
||||
export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOptions) {
|
||||
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 () => {
|
||||
options.onNoResponse()
|
||||
return 'AIRI System: Acknowledged, no response or action will be processed.'
|
||||
},
|
||||
})
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
|
||||
const validated = await validate(sparkNotifyCommandSchema, payload)
|
||||
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
|
||||
}
|
||||
catch (error) {
|
||||
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
|
||||
}
|
||||
|
||||
return 'AIRI System: Acknowledged, command fired.'
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
tools: [
|
||||
sparkNoResponseTool,
|
||||
sparkCommandTool,
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
export type { AgentContextPort } from './contracts/context-port'
|
||||
|
||||
export type { ChatHookRegistry } from './contracts/hook-types'
|
||||
|
||||
export type { AgentLLMPort } from './contracts/llm-port'
|
||||
export type { AgentSessionPort } from './contracts/session-port'
|
||||
export type { AgentForegroundStreamPort } from './contracts/stream-port'
|
||||
|
||||
export { createChatHooks } from './runtime/agent-hooks'
|
||||
export type { ContextHistoryEntry, ContextRegistry } from './runtime/context-registry'
|
||||
|
||||
export { createContextRegistry } from './runtime/context-registry'
|
||||
export {
|
||||
isToolRelatedError,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown'
|
||||
export default defineConfig({
|
||||
entry: [
|
||||
'src/index.ts',
|
||||
'src/agents/spark-notify/index.ts',
|
||||
],
|
||||
dts: true,
|
||||
})
|
||||
|
||||
+10
-183
@@ -1,183 +1,10 @@
|
||||
import type { WebSocketEventOf, WebSocketEvents } 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 { SparkNotifyCommandDraft } from '../../../../../tools'
|
||||
import type { StreamEvent } from '../../../../llm'
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
import { createSparkNotifyTools } from '../../../../../tools'
|
||||
import { getEventSourceKey } from '../../../../../utils'
|
||||
|
||||
export type { SparkNotifyCommandDraft, SparkNotifyCommandSchema } from '../../../../../tools'
|
||||
export { sparkNotifyCommandSchema } from '../../../../../tools'
|
||||
|
||||
export interface SparkNotifyResponse {
|
||||
reaction?: string
|
||||
commands?: SparkNotifyCommandDraft[]
|
||||
}
|
||||
|
||||
export interface SparkNotifyAgentDeps {
|
||||
stream: (
|
||||
model: string,
|
||||
provider: ChatProvider,
|
||||
messages: Message[],
|
||||
options: {
|
||||
tools?: any[]
|
||||
supportsTools?: boolean
|
||||
waitForTools?: boolean
|
||||
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
|
||||
},
|
||||
) => Promise<void>
|
||||
getActiveProvider: () => string | undefined
|
||||
getActiveModel: () => string | undefined
|
||||
getProviderInstance: <R extends
|
||||
| ChatProvider
|
||||
| ChatProviderWithExtraOptions
|
||||
| EmbedProvider
|
||||
| EmbedProviderWithExtraOptions
|
||||
| SpeechProvider
|
||||
| SpeechProviderWithExtraOptions
|
||||
| TranscriptionProvider
|
||||
| TranscriptionProviderWithExtraOptions,
|
||||
>(name: string,
|
||||
) => Promise<R>
|
||||
onReactionDelta: (eventId: string, text: string) => void
|
||||
onReactionEnd: (eventId: string, text: string) => void
|
||||
getSystemPrompt: () => string
|
||||
getProcessing: () => boolean
|
||||
setProcessing: (next: boolean) => void
|
||||
getPending: () => Array<WebSocketEventOf<'spark:notify'>>
|
||||
setPending: (next: Array<WebSocketEventOf<'spark:notify'>>) => void
|
||||
}
|
||||
|
||||
function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
|
||||
return [
|
||||
'This is AIRI system, the life pod hosting your consciousness. You don\'t need to respond to me or every spark:notify event directly.',
|
||||
`Another module "${moduleName}" triggered spark:notify event for you to checkout.`,
|
||||
'You may call the built-in tool "builtIn_sparkCommand" to issue spark:command to sub-agents as needed.',
|
||||
'For any of the output that is not a tool call, it will be streamed to user\'s interface and maybe processed with text to speech system ',
|
||||
'to be played out loud as your actual reaction to the spark:notify event.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
|
||||
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
|
||||
const activeProvider = deps.getActiveProvider()
|
||||
const activeModel = deps.getActiveModel()
|
||||
if (!activeProvider || !activeModel) {
|
||||
console.warn('Spark notify ignored: missing active provider or model')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const chatProvider = await deps.getProviderInstance<ChatProvider>(activeProvider)
|
||||
const commandDrafts: SparkNotifyCommandDraft[] = []
|
||||
|
||||
let noResponse = false
|
||||
|
||||
const { tools } = await createSparkNotifyTools({
|
||||
onNoResponse: () => {
|
||||
noResponse = true
|
||||
},
|
||||
onCommands: commands => commandDrafts.push(...commands),
|
||||
})
|
||||
|
||||
const systemMessage: Message = {
|
||||
role: 'system',
|
||||
content: [
|
||||
deps.getSystemPrompt(),
|
||||
getSparkNotifyHandlingAgentInstruction(getEventSourceKey(event)),
|
||||
].filter(Boolean).join('\n\n'),
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
notify: event.data,
|
||||
source: event.source,
|
||||
}, null, 2),
|
||||
}
|
||||
|
||||
let fullText = ''
|
||||
|
||||
await deps.stream(activeModel, chatProvider, [systemMessage, userMessage], {
|
||||
tools,
|
||||
supportsTools: true,
|
||||
waitForTools: true,
|
||||
onStreamEvent: async (streamEvent: StreamEvent) => {
|
||||
if (streamEvent.type === 'text-delta') {
|
||||
if (noResponse)
|
||||
return
|
||||
|
||||
deps.onReactionDelta(event.data.id, streamEvent.text)
|
||||
|
||||
fullText += streamEvent.text
|
||||
}
|
||||
if (streamEvent.type === 'finish') {
|
||||
if (noResponse) {
|
||||
deps.onReactionEnd(event.data.id, '')
|
||||
return
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
reaction: fullText.trim(),
|
||||
commands: commandDrafts,
|
||||
} satisfies SparkNotifyResponse
|
||||
}
|
||||
|
||||
async function handle(event: WebSocketEventOf<'spark:notify'>) {
|
||||
if (event.data.urgency !== 'immediate' && deps.getPending().length > 0) {
|
||||
deps.setPending([...deps.getPending(), event])
|
||||
return undefined
|
||||
}
|
||||
if (deps.getProcessing()) {
|
||||
deps.setPending([...deps.getPending(), event])
|
||||
return undefined
|
||||
}
|
||||
|
||||
deps.setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await runNotifyAgent(event)
|
||||
if (!response)
|
||||
return undefined
|
||||
|
||||
const commands = (response.commands ?? [])
|
||||
.map(command => ({
|
||||
id: nanoid(),
|
||||
eventId: nanoid(),
|
||||
parentEventId: event.data.id,
|
||||
commandId: nanoid(),
|
||||
interrupt: (command.interrupt === true ? 'force' : command.interrupt) ?? false,
|
||||
priority: command.priority ?? 'normal',
|
||||
intent: command.intent ?? 'action',
|
||||
ack: command.ack,
|
||||
guidance: command.guidance,
|
||||
contexts: command.contexts,
|
||||
destinations: command.destinations ?? [],
|
||||
} satisfies WebSocketEvents['spark:command']))
|
||||
.filter(command => command.destinations.length > 0)
|
||||
|
||||
return {
|
||||
commands,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
deps.setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
}
|
||||
}
|
||||
export type {
|
||||
SparkNotifyAgentDeps,
|
||||
SparkNotifyCommandDraft,
|
||||
SparkNotifyCommandSchema,
|
||||
SparkNotifyResponse,
|
||||
} from '@proj-airi/core-agent/agents/spark-notify'
|
||||
export {
|
||||
setupAgentSparkNotifyHandler,
|
||||
sparkNotifyCommandSchema,
|
||||
} from '@proj-airi/core-agent/agents/spark-notify'
|
||||
|
||||
@@ -99,7 +99,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
for (const command of result.commands) {
|
||||
modsServerChannelStore.send({
|
||||
type: 'spark:command',
|
||||
data: command,
|
||||
data: command as WebSocketEvents['spark:command'],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,108 +1,9 @@
|
||||
import type { WebSocketEvents } from '@proj-airi/server-sdk'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { rawTool } from '@xsai/tool'
|
||||
import { toJsonSchema, validate } from 'xsschema'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
normalizeNullableAnyOf,
|
||||
sparkNotifyCommandItemSchema,
|
||||
} from './spark-command-shared'
|
||||
|
||||
export interface SparkNotifyCommandDraft {
|
||||
destinations: string[]
|
||||
interrupt?: 'force' | 'soft' | boolean
|
||||
priority?: 'critical' | 'high' | 'normal' | 'low'
|
||||
intent?: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
|
||||
ack?: string
|
||||
guidance?: WebSocketEvents['spark:command']['guidance']
|
||||
contexts?: WebSocketEvents['spark:command']['contexts']
|
||||
}
|
||||
|
||||
export const sparkNotifyCommandSchema = z.object({
|
||||
commands: z.array(sparkNotifyCommandItemSchema).describe('List of commands to issue to sub-agents, you may produce multiple commands in response to multiple sub-agents by specifying their IDs in destination field. Empty array can be used for zero commands.'),
|
||||
}).strict()
|
||||
|
||||
export type SparkNotifyCommandSchema = z.infer<typeof sparkNotifyCommandSchema>
|
||||
|
||||
export interface CreateSparkNotifyToolsOptions {
|
||||
onCommands: (commands: SparkNotifyCommandDraft[]) => void
|
||||
onNoResponse: () => void
|
||||
}
|
||||
|
||||
function normalizeSparkNotifyCommand(
|
||||
command: z.infer<typeof sparkNotifyCommandSchema>['commands'][number],
|
||||
): SparkNotifyCommandDraft {
|
||||
// NOTICE: The notify-agent tool schema preserves the LLM-facing payload shape, but the
|
||||
// orchestrator stores runtime drafts in the websocket event shape. This normalizes array
|
||||
// persona entries and nullable guidance fields back into the draft shape expected downstream.
|
||||
return {
|
||||
destinations: command.destinations,
|
||||
guidance: command.guidance
|
||||
? {
|
||||
type: command.guidance.type,
|
||||
persona: command.guidance.persona?.reduce((acc, curr) => {
|
||||
acc[curr.traits] = curr.strength
|
||||
return acc
|
||||
}, {} as Record<string, 'very-high' | 'high' | 'medium' | 'low' | 'very-low'>) || undefined,
|
||||
options: command.guidance.options.map(option => ({
|
||||
...option,
|
||||
rationale: option.rationale ?? undefined,
|
||||
possibleOutcome: option.possibleOutcome?.length ? option.possibleOutcome : undefined,
|
||||
risk: option.risk ?? undefined,
|
||||
fallback: option.fallback?.length ? option.fallback : undefined,
|
||||
triggers: option.triggers?.length ? option.triggers : undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
// TODO: contexts can be added later
|
||||
contexts: [],
|
||||
priority: command.priority || 'normal',
|
||||
intent: command.intent || 'action',
|
||||
ack: command.ack || undefined,
|
||||
interrupt: command.interrupt === 'false' || command.interrupt == null ? false : command.interrupt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSparkNotifyTools(options: CreateSparkNotifyToolsOptions) {
|
||||
const sparkNoResponseTool = rawTool({
|
||||
name: 'builtIn_sparkNoResponse',
|
||||
description: 'Indicate that no response or action is needed for the current spark:notify event.',
|
||||
// NOTICE: Keep the same raw-tool + normalized-schema path as the general spark command
|
||||
// tool so built-in notify tools do not regress on the same provider-specific schema checks.
|
||||
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 sparkCommandTool = rawTool({
|
||||
name: 'builtIn_sparkCommand',
|
||||
description: 'Issue a spark:command to sub-agents. You can call this tool multiple times to issue matrices of commands to different sub-agents as needed.',
|
||||
// NOTICE: `sparkNotifyCommandSchema` keeps the notify-agent input shape, but its emitted
|
||||
// JSON Schema still passes through the shared nullable-union normalizer before rawTool
|
||||
// freezes it for OpenAI-compatible providers.
|
||||
parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any),
|
||||
execute: async (rawPayload) => {
|
||||
try {
|
||||
const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema>
|
||||
const validated = await validate(sparkNotifyCommandSchema, payload)
|
||||
options.onCommands(validated.commands.map(normalizeSparkNotifyCommand))
|
||||
}
|
||||
catch (error) {
|
||||
return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}`
|
||||
}
|
||||
|
||||
return 'AIRI System: Acknowledged, command fired.'
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
tools: [
|
||||
sparkNoResponseTool,
|
||||
sparkCommandTool,
|
||||
],
|
||||
}
|
||||
}
|
||||
export type {
|
||||
CreateSparkNotifyToolsOptions,
|
||||
SparkNotifyCommandDraft,
|
||||
SparkNotifyCommandSchema,
|
||||
} from '@proj-airi/core-agent/agents/spark-notify'
|
||||
export {
|
||||
createSparkNotifyTools,
|
||||
sparkNotifyCommandSchema,
|
||||
} from '@proj-airi/core-agent/agents/spark-notify'
|
||||
|
||||
Generated
+18
@@ -2359,6 +2359,12 @@ importers:
|
||||
|
||||
packages/core-agent:
|
||||
dependencies:
|
||||
'@moeru/std':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.0-beta.17
|
||||
'@proj-airi/server-sdk':
|
||||
specifier: workspace:^
|
||||
version: link:../server-sdk
|
||||
'@proj-airi/server-shared':
|
||||
specifier: workspace:^
|
||||
version: link:../server-shared
|
||||
@@ -2374,6 +2380,18 @@ importers:
|
||||
'@xsai/stream-text':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.0-beta.2(patch_hash=90dfe10d02f5946658508ec019937eab600745c93446ce7b2fdb1a0ed70e3e49)
|
||||
'@xsai/tool':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.0-beta.2(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
|
||||
nanoid:
|
||||
specifier: ^5.1.7
|
||||
version: 5.1.7
|
||||
xsschema:
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.3.1(typescript@5.9.3)))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
tsdown:
|
||||
specifier: 'catalog:'
|
||||
|
||||
Reference in New Issue
Block a user