Files
ycode/src/main/omp.ts
T

481 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OMP 内核 sidecar(`omp --mode rpc`)。
*
* OMP RPC uses newline-delimited JSON frames over stdio:
* - stdio 上的 **newline-delimited JSONL**(不是 JSON-RPC 2.0)
* - 启动后先写一个 `ready` 帧
* - stdin:命令(可带 `id` 用于关联);stdout:响应 + 会话事件
* - 单帧上限 1 MiB
*
* 持久会话使用 OMP 自己的 JSONL transcript;未提供 sessionPath 时保持临时模式。
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { createInterface } from 'node:readline'
import { EventEmitter } from 'node:events'
import { existsSync, readFileSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/** OMP 单帧上限(含换行) */
const MAX_FRAME_BYTES = 1024 * 1024
/** 命令默认超时 */
const COMMAND_TIMEOUT_MS = 60_000
export interface OmpFrame<TData = unknown> {
type: string
id?: string
command?: string
success?: boolean
data?: TData
error?: string | { message?: string }
[key: string]: unknown
}
export interface OmpAgentMessage {
role: string
content?: unknown[]
[key: string]: unknown
}
export interface OmpSessionTreeEntry {
id: string
parentId: string | null
type: string
message?: OmpAgentMessage
[key: string]: unknown
}
export interface OmpSessionTreeNode {
entry: OmpSessionTreeEntry
children: OmpSessionTreeNode[]
label?: string
}
export interface OmpBranchMessage {
entryId: string
text: string
}
export interface OmpHostToolDefinition {
name: string
label?: string
description: string
parameters: Record<string, unknown>
}
export interface OmpToolInfo {
id?: string
name?: string
/** Optional human-readable action text emitted alongside the native tool call. */
intent?: string
argsRaw?: string
/** 部分输出 / 最终输出(尽力解析) */
output?: string
ok?: boolean
isError?: boolean
}
export interface OmpPromptImage {
type: 'image'
data: string
mimeType: string
}
interface Pending {
resolve: (value: OmpFrame) => void
reject: (err: Error) => void
timer: NodeJS.Timeout
}
/**
* 随包内置的 OMP:打包后位于 resources/omp,开发态位于项目 resources/omp。
* 版本与摘要由 resources/omp/runtime.lock.json 锁定(npm run omp:verify 校验)。
*/
function detectBundledOmpPath(): string | null {
let moduleBundled: string | null = null
if (typeof __dirname === 'string' && __dirname) {
moduleBundled = resolve(__dirname, '..', '..', 'resources', 'omp', 'omp.exe')
} else if (typeof import.meta.url === 'string') {
try {
moduleBundled = fileURLToPath(new URL('../../resources/omp/omp.exe', import.meta.url))
} catch {
// CJS test bundles may expose an empty import.meta object.
}
}
const candidates = [
process.resourcesPath ? join(process.resourcesPath, 'omp', 'omp.exe') : null,
join(process.cwd(), 'resources', 'omp', 'omp.exe'),
moduleBundled
]
for (const candidate of candidates) {
if (candidate && existsSync(candidate)) return candidate
}
return null
}
/** 探测 OMP 可执行文件:显式覆盖 > 随包内置 > 本机 Ygent 安装 */
export function detectOmpPath(): string | null {
const candidates = [
process.env['YCODE_OMP_PATH'],
detectBundledOmpPath(),
process.env['LOCALAPPDATA'] ? `${process.env['LOCALAPPDATA']}\\Ygent\\omp.exe` : null,
'C:\\Program Files\\Ygent\\omp.exe'
].filter((p): p is string => !!p)
for (const p of candidates) {
if (existsSync(p)) return p
}
return null
}
/** OMP 内核的对外信息(设置页 / 输入框展示用) */
export interface OmpInfo {
available: boolean
exePath: string | null
profile: string
/** 从 profile 的 models.yml 读出的首个自定义模型(`<provider>/<id>` 形式),读不到为 null */
model: string | null
}
/** Returns the first custom model with its OMP provider namespace. */
export function parseFirstProfileModel(modelsYaml: string): string | null {
let inProviders = false
let providerId: string | null = null
let inModels = false
for (const line of modelsYaml.split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith('#')) continue
const indent = line.length - line.trimStart().length
const content = line.trimStart()
if (indent === 0) {
inProviders = /^providers:\s*(?:#.*)?$/.test(content)
providerId = null
inModels = false
continue
}
if (!inProviders) continue
if (indent === 2) {
const provider = content.match(/^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9._-]+)):\s*(?:#.*)?$/)
providerId = provider?.[1] ?? provider?.[2] ?? provider?.[3] ?? null
inModels = false
continue
}
if (!providerId) continue
if (indent === 4) {
inModels = /^models:\s*(?:#.*)?$/.test(content)
continue
}
if (!inModels || indent < 6) continue
const model = content.match(/^[-]\s*id:\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))\s*(?:#.*)?$/)
const modelId = model?.[1] ?? model?.[2] ?? model?.[3]
if (modelId) return `${providerId}/${modelId}`
}
return null
}
/** Reads the executable and first provider/model pair declared in the selected profile. */
export function getOmpInfo(profile = 'ycode'): OmpInfo {
const exePath = detectOmpPath()
if (!exePath) {
return { available: false, exePath: null, profile, model: null }
}
const modelsPath = join(homedir(), '.omp', 'profiles', profile, 'agent', 'models.yml')
let model: string | null = null
try {
const text = readFileSync(modelsPath, 'utf8')
model = parseFirstProfileModel(text)
} catch {
// profile 未配置时只暴露可用性
}
return { available: true, exePath, profile, model }
}
export class OmpKernel extends EventEmitter {
get isStarted(): boolean { return this.readyDone && !this.disposed }
private child: ChildProcessWithoutNullStreams | null = null
private pending = new Map<string, Pending>()
private seq = 0
private starting: Promise<void> | null = null
private readyDone = false
private disposed = false
private disposal: Promise<void> | null = null
private startupErrorReported = false
constructor(
private readonly exePath: string,
private readonly cwd: string,
private readonly profile = 'ycode',
private readonly sessionPath?: string,
private readonly modelOverride?: string | null,
private readonly environment?: NodeJS.ProcessEnv
) {
super()
}
/** 启动进程并等待 ready 帧;重复调用只会启动一次 */
async start(): Promise<void> {
if (this.disposed) throw new Error('OMP 内核已释放')
if (this.readyDone) return
if (this.starting) return this.starting
this.startupErrorReported = false
const starting = this.startProcess().catch((err: Error) => {
if (this.starting === starting) this.starting = null
if (!this.startupErrorReported && this.listenerCount('error') > 0) {
this.startupErrorReported = true
this.emit('error', err)
}
throw err
})
this.starting = starting
return starting
}
private async startProcess(): Promise<void> {
if (!existsSync(this.exePath)) {
throw new Error(`OMP 可执行文件不存在:${this.exePath}`)
}
if (this.sessionPath) {
if (!isAbsolute(this.sessionPath)) throw new Error('OMP 会话路径必须是绝对路径')
await mkdir(dirname(this.sessionPath), { recursive: true })
}
if (this.disposed) throw new Error('OMP 内核已释放')
// spawn 的 cwd 必须存在,否则报 ENOENT(与可执行文件无关)
const cwd = existsSync(this.cwd) ? this.cwd : homedir()
const model = this.modelOverride === undefined ? getOmpInfo(this.profile).model : this.modelOverride
if (!model || model.indexOf('/') <= 0 || model.endsWith('/')) {
throw new Error(`OMP profile "${this.profile}" 缺少有效的 provider/model;请在 agent/models.yml 中配置模型`)
}
const args = ['--mode', 'rpc', '--profile', this.profile, `--model=${model}`, '--cwd', cwd, '--no-tools']
if (this.environment?.YCODE_MODEL_CONFIG) args.push('--config', this.environment.YCODE_MODEL_CONFIG)
if (this.environment?.YCODE_CONTEXT_OBSERVER_EXTENSION) args.push('--extension',this.environment.YCODE_CONTEXT_OBSERVER_EXTENSION)
if (this.sessionPath) {
args.push(`--session-dir=${dirname(this.sessionPath)}`, `--resume=${this.sessionPath}`)
} else {
args.push('--no-session')
}
const child = spawn(this.exePath, args, { cwd, windowsHide: true, env: { ...process.env, ...this.environment } })
this.child = child
const rl = createInterface({ input: child.stdout })
await new Promise<void>((resolve, reject) => {
let settled = false
let startupTimer: NodeJS.Timeout | undefined
const settle = (err?: Error): void => {
if (settled) return
settled = true
if (startupTimer) clearTimeout(startupTimer)
this.off('frame', onReady)
if (err) reject(err)
else resolve()
}
const onReady = (frame: OmpFrame): void => {
if (frame.type !== 'ready') return
this.readyDone = true
settle()
}
child.on('error', (err) => {
this.startupErrorReported = true
if (this.listenerCount('error') > 0) this.emit('error', err)
settle(err)
})
child.on('exit', (code) => {
const wasReady = this.readyDone
this.emit('exit', code)
rl.close()
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(new Error(`OMP 进程已退出(code=${code}),命令 ${id} 未完成`))
}
this.pending.clear()
if (this.child === child) this.child = null
this.readyDone = false
this.starting = null
if (!wasReady) settle(new Error(`OMP 进程在 ready 前退出(code=${code})`))
})
child.stderr.on('data', (chunk: Buffer) => {
this.emit('stderr', chunk.toString())
})
rl.on('line', (line) => this.handleLine(line))
this.on('frame', onReady)
startupTimer = setTimeout(() => settle(new Error('OMP 启动超时(未收到 ready 帧)')), 30_000)
startupTimer.unref()
})
}
private handleLine(line: string): void {
const trimmed = line.trim()
if (!trimmed) return
if (trimmed.length > MAX_FRAME_BYTES) {
this.emit('error', new Error(`OMP 帧超过上限(${trimmed.length} 字节)`))
return
}
let frame: OmpFrame
try {
frame = JSON.parse(trimmed) as OmpFrame
} catch {
this.emit('error', new Error(`OMP 输出无法解析:${trimmed.slice(0, 200)}`))
return
}
// 带响应 id 的帧 → 关联到挂起命令
if (frame.id && this.pending.has(frame.id)) {
const p = this.pending.get(frame.id)
if (p) {
this.pending.delete(frame.id)
clearTimeout(p.timer)
p.resolve(frame)
}
// 响应帧同时也广播(prompt 的 ack 可能带 agentInvoked 信息)
this.emit('frame', frame)
return
}
this.emit('frame', frame)
this.emit('event', frame)
}
/** 发送一条命令并等待其响应 */
async send(command: Record<string, unknown>, timeoutMs = COMMAND_TIMEOUT_MS): Promise<OmpFrame> {
await this.start()
const id = `req_${++this.seq}`
const payload = JSON.stringify({ id, ...command })
return new Promise<OmpFrame>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`OMP 命令超时:${String(command.type)}`))
}, timeoutMs)
timer.unref?.()
this.pending.set(id, { resolve, reject, timer })
this.child?.stdin.write(payload + '\n')
})
}
/** 发起一次对话回合;事件经 `event` 回调流出 */
async prompt(message: string, images?: OmpPromptImage[]): Promise<OmpFrame> {
const command: Record<string, unknown> = { type: 'prompt', message }
if (images?.length) command.images = images
return this.send(command, COMMAND_TIMEOUT_MS * 10)
}
async abort(): Promise<OmpFrame> {
return this.send({ type: 'abort' }, 15_000)
}
async getMessages(): Promise<OmpFrame<{ messages: OmpAgentMessage[] }>> {
return this.send({ type: 'get_messages' }) as Promise<OmpFrame<{ messages: OmpAgentMessage[] }>>
}
async getTree(): Promise<OmpFrame<{ tree: OmpSessionTreeNode[]; leafId: string | null }>> {
return this.send({ type: 'get_tree' }) as Promise<OmpFrame<{ tree: OmpSessionTreeNode[]; leafId: string | null }>>
}
async getBranchMessages(): Promise<OmpFrame<{ messages: OmpBranchMessage[] }>> {
return this.send({ type: 'get_branch_messages' }) as Promise<OmpFrame<{ messages: OmpBranchMessage[] }>>
}
async branch(entryId: string): Promise<OmpFrame<{ text?: string; cancelled?: boolean }>> {
if (!entryId.trim()) throw new Error('OMP branch requires a native user message entryId')
return this.send({ type: 'branch', entryId }) as Promise<OmpFrame<{ text?: string; cancelled?: boolean }>>
}
async setHostTools(tools: OmpHostToolDefinition[]): Promise<OmpFrame<{ toolNames: string[] }>> {
return this.send({ type: 'set_host_tools', tools }) as Promise<OmpFrame<{ toolNames: string[] }>>
}
/** Levels come from `getAvailableThinkingLevels()`; `off` disables thinking for this session. */
async setThinkingLevel(level: string): Promise<void> {
await this.send({ type: 'set_thinking_level', level })
}
async getAvailableThinkingLevels(): Promise<string[]> {
const frame = await this.send({ type: 'get_available_thinking_levels' }) as OmpFrame<{ levels?: string[] }>
return Array.isArray(frame.levels) ? frame.levels : []
}
/** Resolve an OMP host_tool_call. The host request id, not toolCallId, correlates the reply. */
async respondToHostTool(requestId: string, content: string, isError = false, images?: OmpPromptImage[]): Promise<void> {
await this.start()
if (!requestId.trim()) throw new Error('OMP host tool response requires a request id')
const child = this.child
if (!child) throw new Error('OMP sidecar is not running')
const payload = {
type: 'host_tool_result',
id: requestId,
result: { content: [{ type: 'text', text: content }, ...(!isError ? images ?? [] : [])] },
...(isError ? { isError: true } : {})
}
const frame = `${JSON.stringify(payload)}\n`
if (Buffer.byteLength(frame) > MAX_FRAME_BYTES) throw new Error('OMP 工具结果超过单帧大小限制')
child.stdin.write(frame)
}
dispose(): Promise<void> {
if (this.disposal) return this.disposal
this.disposed = true
const child = this.child
if (!child) {
this.removeAllListeners()
this.disposal = Promise.resolve()
return this.disposal
}
this.disposal = new Promise<void>((resolve) => {
let completed = false
let killTimer: NodeJS.Timeout | undefined
const finish = (): void => {
if (completed) return
completed = true
if (killTimer) clearTimeout(killTimer)
child.off('exit', finish)
child.off('close', finish)
child.off('error', finishOnSpawnError)
if (this.child === child) this.child = null
this.removeAllListeners()
resolve()
}
const finishOnSpawnError = (): void => {
if (child.pid === undefined) finish()
}
if (child.exitCode !== null || child.signalCode !== null) {
finish()
return
}
child.once('exit', finish)
child.once('close', finish)
child.on('error', finishOnSpawnError)
try {
child.stdin.end()
} catch {
// 进程可能已退出
}
killTimer = setTimeout(() => {
if (child.exitCode !== null || child.signalCode !== null) return
try {
child.kill()
} catch {
// exit / close 事件仍负责完成释放
}
}, 1500)
killTimer.unref()
})
return this.disposal
}
}