From ffebac582d9990890ee4cee0036c221597028b4a Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 27 May 2026 13:17:40 +0800 Subject: [PATCH 1/3] [Refactor] simplify agent extension --- .../src/computer/backends/e2b.ts | 214 ++------ .../src/computer/backends/local/index.ts | 124 ++--- .../src/computer/backends/local/sandbox.ts | 129 ++--- .../src/computer/backends/local/security.ts | 57 +- .../src/computer/backends/local/shell.ts | 164 +++--- .../src/computer/backends/local/store.ts | 95 ++-- .../src/computer/backends/open_terminal.ts | 141 +++-- .../src/computer/backends/types.ts | 21 +- .../src/computer/background.ts | 20 +- .../src/computer/materialize.ts | 71 +-- .../extension-agent/src/computer/proxy.ts | 34 +- .../extension-agent/src/computer/session.ts | 79 +-- .../src/computer/tools/base.ts | 4 +- .../src/computer/tools/bash.ts | 67 +-- .../src/computer/tools/file_edit.ts | 7 +- .../src/computer/tools/file_read.ts | 7 +- .../src/computer/tools/file_write.ts | 7 +- .../src/computer/tools/glob.ts | 7 +- .../src/computer/tools/grep.ts | 7 +- .../src/computer/tools/publish_file.ts | 4 +- .../extension-agent/src/config/defaults.ts | 496 +++++------------- packages/extension-agent/src/config/read.ts | 101 ++-- .../extension-agent/src/service/computer.ts | 204 +++---- packages/extension-agent/src/service/index.ts | 76 +-- packages/extension-agent/src/service/mcp.ts | 34 +- .../src/service/permissions.ts | 152 +++--- .../extension-agent/src/service/skills.ts | 59 +-- .../extension-agent/src/service/sub_agent.ts | 20 +- .../extension-agent/src/service/trigger.ts | 134 ++--- .../extension-agent/src/skills/builtin.ts | 46 +- .../extension-agent/src/skills/catalog.ts | 43 +- packages/extension-agent/src/skills/import.ts | 318 +++++------ packages/extension-agent/src/skills/manage.ts | 33 +- packages/extension-agent/src/skills/render.ts | 24 +- packages/extension-agent/src/skills/scan.ts | 254 ++++----- packages/extension-agent/src/skills/slash.ts | 16 +- packages/extension-agent/src/skills/tool.ts | 4 +- packages/extension-agent/src/skills/watch.ts | 32 +- .../extension-agent/src/sub-agent/builtin.ts | 105 ++-- .../extension-agent/src/sub-agent/catalog.ts | 27 +- .../extension-agent/src/sub-agent/manual.ts | 104 +--- .../extension-agent/src/sub-agent/markdown.ts | 22 +- .../extension-agent/src/sub-agent/parse.ts | 355 ++++++------- .../extension-agent/src/sub-agent/preset.ts | 6 +- .../extension-agent/src/sub-agent/render.ts | 35 +- packages/extension-agent/src/sub-agent/run.ts | 194 +++---- .../extension-agent/src/sub-agent/runtime.ts | 5 +- .../extension-agent/src/sub-agent/scan.ts | 280 ++++------ .../extension-agent/src/sub-agent/session.ts | 32 +- .../extension-agent/src/sub-agent/tool.ts | 13 +- .../src/utils/agentcli_sync.ts | 6 +- packages/extension-agent/src/utils/fs.ts | 10 +- packages/extension-agent/src/utils/path.ts | 30 +- .../extension-agent/src/utils/remote_path.ts | 6 +- .../extension-agent/src/utils/runtime_sync.ts | 9 +- packages/extension-agent/src/utils/shadow.ts | 34 +- 56 files changed, 1748 insertions(+), 2830 deletions(-) diff --git a/packages/extension-agent/src/computer/backends/e2b.ts b/packages/extension-agent/src/computer/backends/e2b.ts index 62139cb61..b225991ad 100644 --- a/packages/extension-agent/src/computer/backends/e2b.ts +++ b/packages/extension-agent/src/computer/backends/e2b.ts @@ -16,7 +16,7 @@ import { import mimeTypes from 'mime-types' import { Context } from 'koishi' import { buildPosixBackgroundCommand, quoteShell } from './types' -import { E2BBackendConfig } from '../../types' +import { ComputerCapability, E2BBackendConfig } from '../../types' import { getErrorMessage } from '../../utils/shell' import { ComputerSessionApi, @@ -40,13 +40,24 @@ interface SandboxWrapper { setTimeout(timeoutMs: number): Promise pause(apiKey?: string): Promise kill(): Promise - desktop?: never } export class E2BComputerSession implements ComputerSessionApi { readonly backend = 'e2b' as const readonly sessionId: string - readonly capabilities = [...CAPABILITIES] + readonly capabilities: ComputerCapability[] = [ + 'file_read', + 'file_write', + 'file_edit', + 'file_publish', + 'grep', + 'glob', + 'bash', + 'terminal_pty', + 'desktop_stream', + 'desktop_screenshot', + 'desktop_action' + ] private _connected = false private _connecting?: Promise @@ -123,22 +134,20 @@ export class E2BComputerSession implements ComputerSessionApi { } await sandbox.setTimeout(this.cfg.timeoutMs) - const current = await this.run( - 'pwd', - { - timeoutMs: 5000 - } as CommandStartOpts, - sandbox - ) - this._home = current.stdout.trim() || '/' + this._home = + ( + await this.run( + 'pwd', + { timeoutMs: 5000 } as CommandStartOpts, + sandbox + ) + ).stdout.trim() || '/' if (this.options.cwd) { const cwd = this.resolvePath(this.options.cwd) const stat = await this.run( `if [ -d ${quoteShell(cwd)} ]; then printf __dir__; fi`, - { - timeoutMs: 5000 - } as CommandStartOpts, + { timeoutMs: 5000 } as CommandStartOpts, sandbox ) if (stat.stdout.trim() === '__dir__') { @@ -184,11 +193,6 @@ export class E2BComputerSession implements ComputerSessionApi { return } - const desktop = this.ensureDesktopSandbox() - if (desktop) { - await desktop.stream.stop().catch(() => undefined) - } - try { if (this.cfg.keepAlive) { await this._sandbox.pause(this.resolveSecret(this.cfg.apiKey)) @@ -226,8 +230,9 @@ export class E2BComputerSession implements ComputerSessionApi { return result.stdout.trim() } - const raw = await (await this.ensureSandbox()).files.read(target) - const text = String(raw) + const text = String( + await (await this.ensureSandbox()).files.read(target) + ) if (offset == null && limit == null) { return text } @@ -256,10 +261,9 @@ export class E2BComputerSession implements ComputerSessionApi { return } - const dir = posix.dirname(target) const tmp = `${target}.${randomUUID()}.base64` - await this.execute(`mkdir -p ${quoteShell(dir)}`) + await this.execute(`mkdir -p ${quoteShell(posix.dirname(target))}`) await sandbox.files.write( tmp, Buffer.from(content).toString('base64') @@ -298,8 +302,7 @@ export class E2BComputerSession implements ComputerSessionApi { if (replaceCount === 1) { const firstIdx = content.indexOf(oldString) - const secondIdx = content.indexOf(oldString, firstIdx + 1) - if (secondIdx !== -1) { + if (content.indexOf(oldString, firstIdx + 1) !== -1) { throw new Error( `Found multiple matches for oldString in ${filePath}. ` + 'Provide more surrounding lines in oldString to identify the correct match, or set replaceAll to change every instance.' @@ -403,16 +406,15 @@ export class E2BComputerSession implements ComputerSessionApi { const sandbox = await this.ensureSandbox() const target = this.resolvePath(filePath) const info = await sandbox.files.getInfo(target) - const stream = await sandbox.files.read(target, { - format: 'stream' - }) - const mimeType = mimeTypes.lookup(filePath) + const mime = mimeTypes.lookup(filePath) return { stream: Readable.fromWeb( - stream as unknown as globalThis.ReadableStream + (await sandbox.files.read(target, { + format: 'stream' + })) as unknown as globalThis.ReadableStream ), size: info.size, - mimeType: mimeType === false ? undefined : mimeType + mimeType: mime === false ? undefined : mime } } catch (err) { this.ctx.logger.error(err) @@ -434,8 +436,8 @@ export class E2BComputerSession implements ComputerSessionApi { timeoutMs: this.cfg.timeoutMs, onData: (data) => { const text = Buffer.from(data).toString('utf8') - for (const callback of callbacks) { - callback(text) + for (const cb of callbacks) { + cb(text) } } }) @@ -500,116 +502,20 @@ export class E2BComputerSession implements ComputerSessionApi { } async getDesktopInfo(): Promise { - // const desktop = this.ensureDesktopSandbox() - // if (!desktop) { - // return undefined - // } - - // await desktop.stream.start().catch(() => undefined) - // const size = await desktop.getScreenSize() - // return { - // width: size.width, - // height: size.height, - // streamUrl: desktop.stream.getUrl({ - // autoConnect: true, - // resize: 'scale' - // }) - // } return null } async screenshot(): Promise { - // const desktop = this.ensureDesktopSandbox() - // if (!desktop) { throw new Error('Desktop is not enabled for this E2B session.') - // } - - // const bytes = await desktop.screenshot('bytes') - // const size = await desktop.getScreenSize() - // return { - // data: Buffer.from(bytes).toString('base64'), - // mimeType: 'image/png', - // width: size.width, - // height: size.height - // } - // } + // eslint-disable-next-line @typescript-eslint/no-unused-vars async desktopAction(action: DesktopAction) { - const desktop = this.ensureDesktopSandbox() - if (!desktop) { - throw new Error('Desktop is not enabled for this E2B session.') - } - - // if (action.type === 'click') { - // if (action.button === 'right') { - // await desktop.rightClick(action.x, action.y) - // return - // } - // if (action.button === 'middle') { - // await desktop.middleClick(action.x, action.y) - // return - // } - // await desktop.leftClick(action.x, action.y) - // return - // } - - // if (action.type === 'type') { - // await desktop.write(action.text) - // return - // } - - // if (action.type === 'key') { - // await desktop.press(action.key) - // return - // } - - // if (action.type === 'scroll') { - // const direction = action.deltaY < 0 ? 'up' : 'down' - // await desktop.scroll( - // direction, - // Math.max(1, Math.abs(action.deltaY)) - // ) - // return - // } - - // await desktop.drag( - // [action.startX, action.startY], - // [action.endX, action.endY] - // ) + throw new Error('Desktop is not enabled for this E2B session.') } async getDesktopStream(): Promise { - const desktop = this.ensureDesktopSandbox() - if (!desktop) { - return undefined - } - - try { - await desktop.stream.start() - const ctx = this.ctx - return { - url: desktop.stream.getUrl({ - autoConnect: true, - resize: 'scale' - }), - async stop() { - try { - await desktop.stream.stop() - } catch (err) { - ctx.logger.error(err) - throw new Error( - `Failed to stop desktop stream: ${getErrorMessage(err)}` - ) - } - } - } - } catch (err) { - this.ctx.logger.error(err) - throw new Error( - `Failed to start desktop stream: ${getErrorMessage(err)}` - ) - } + return undefined } isInScope() { @@ -691,15 +597,13 @@ export class E2BComputerSession implements ComputerSessionApi { throw new Error('Command finished without a result.') } - return mapCommandResult(result, timedOut) - } - - private ensureDesktopSandbox() { - return undefined - } - - private usesDesktop() { - return this.cfg.desktopTemplate.length > 0 + return { + exitCode: result.exitCode ?? 0, + stdout: result.stdout, + stderr: result.stderr, + signal: undefined, + timedOut + } } private resolvePath(value: string) { @@ -727,19 +631,6 @@ export class E2BComputerSession implements ComputerSessionApi { } } -function mapCommandResult( - result: CommandResult | CommandHandle, - timedOut = false -) { - return { - exitCode: result.exitCode ?? 0, - stdout: result.stdout, - stderr: result.stderr, - signal: undefined, - timedOut - } -} - function isMissingSandboxError(err: unknown) { if (err instanceof NotFoundError) { return true @@ -764,20 +655,5 @@ function wrapSandbox(sandbox: E2BSandbox): SandboxWrapper { }, kill: () => sandbox.kill(), internal: sandbox - // desktop: sandbox instanceof DesktopSandbox ? sandbox : undefined } } - -const CAPABILITIES = [ - 'file_read', - 'file_write', - 'file_edit', - 'file_publish', - 'grep', - 'glob', - 'bash', - 'terminal_pty', - 'desktop_stream', - 'desktop_screenshot', - 'desktop_action' -] as const diff --git a/packages/extension-agent/src/computer/backends/local/index.ts b/packages/extension-agent/src/computer/backends/local/index.ts index 959caf0cb..827b3c916 100644 --- a/packages/extension-agent/src/computer/backends/local/index.ts +++ b/packages/extension-agent/src/computer/backends/local/index.ts @@ -36,7 +36,16 @@ import { export class LocalComputerSession implements ComputerSessionApi { readonly backend = 'local' as const readonly sessionId: string - readonly capabilities = CAPABILITIES + readonly capabilities: ComputerCapability[] = [ + 'file_read', + 'file_write', + 'file_edit', + 'file_publish', + 'grep', + 'glob', + 'bash', + 'terminal_pty' + ] private _connected = false private _cwd: string @@ -56,7 +65,10 @@ export class LocalComputerSession implements ComputerSessionApi { } async connect() { - await fs.mkdir(tmpdir(this._cfg), { recursive: true }) + await fs.mkdir( + path.join(this._cfg.scopePath || process.cwd(), '.tmp'), + { recursive: true } + ) this._connected = true } @@ -110,7 +122,7 @@ export class LocalComputerSession implements ComputerSessionApi { async execute(command: string, options: ExecuteOptions = {}) { ensureCommandAllowed(command, this._cfg) - const tmp = tmpdir(this._cfg) + const tmp = path.join(this._cfg.scopePath || process.cwd(), '.tmp') const workdir = options.workdir || this._cfg.scopePath || process.cwd() ensureWorkdirInScope(workdir, this._cfg) @@ -122,15 +134,18 @@ export class LocalComputerSession implements ComputerSessionApi { await fs.mkdir(tmp, { recursive: true }) - const timeout = options.timeout ?? this._cfg.commandTimeoutMs const shell = await resolveShellCommand( wrapCommandWithSandbox(command, workdir, this._cfg, tmp), this._cfg ) - const env = { ...shell.env, ...tmpEnv(tmp), ...options.env } this._cwd = path.resolve(workdir) - return await runChildProcess(shell, workdir, env, timeout) + return await runChildProcess( + shell, + workdir, + { ...shell.env, TMP: tmp, TEMP: tmp, TMPDIR: tmp, ...options.env }, + options.timeout ?? this._cfg.commandTimeoutMs + ) } async prepareBackgroundCommand( @@ -140,7 +155,7 @@ export class LocalComputerSession implements ComputerSessionApi { ) { ensureCommandAllowed(command, this._cfg) - const tmp = tmpdir(this._cfg) + const tmp = path.join(this._cfg.scopePath || process.cwd(), '.tmp') const workdir = options.workdir || this._cfg.scopePath || process.cwd() ensureWorkdirInScope(workdir, this._cfg) @@ -161,14 +176,29 @@ export class LocalComputerSession implements ComputerSessionApi { } if (shell.file.toLowerCase().includes('cmd.exe')) { - return buildCmdBackgroundCommand(wrapped, marker) + return ( + [ + wrapped, + 'set "__chatluna_code=%errorlevel%"', + 'echo.', + `echo ${marker}:%__chatluna_code%`, + 'exit /b %__chatluna_code%' + ].join('\r\n') + '\r\n' + ) } if (shell.file.toLowerCase().includes('bash')) { return buildPosixBackgroundCommand(wrapped, marker) } - return buildPowerShellBackgroundCommand(wrapped, marker) + return ( + [ + wrapped, + '$__chatluna_code = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE }', + `Write-Output "\`n${marker}:$($__chatluna_code)"`, + 'exit $__chatluna_code' + ].join('\r\n') + '\r\n' + ) } async readAsset(filePath: string) { @@ -198,13 +228,17 @@ export class LocalComputerSession implements ComputerSessionApi { async createTerminal( options: { cwd?: string; cols?: number; rows?: number } = {} ) { - const tmp = tmpdir(this._cfg) + const tmp = path.join(this._cfg.scopePath || process.cwd(), '.tmp') const cwd = options.cwd || this._cwd ensureWorkdirInScope(cwd, this._cfg) const shell = await resolveInteractiveShellCommand(this._cfg) - const env = { ...shell.env, ...tmpEnv(tmp) } this._cwd = path.resolve(cwd) - return createLocalTerminal(shell, cwd, env) + return createLocalTerminal(shell, cwd, { + ...shell.env, + TMP: tmp, + TEMP: tmp, + TMPDIR: tmp + }) } } @@ -240,8 +274,12 @@ async function runChildProcess( ? setTimeout(() => { finish({ exitCode: 1, - stdout: decodeOutput(stdoutChunks), - stderr: decodeOutput(stderrChunks), + stdout: Buffer.concat(stdoutChunks) + .toString('utf8') + .replace(/\r\n/g, '\n'), + stderr: Buffer.concat(stderrChunks) + .toString('utf8') + .replace(/\r\n/g, '\n'), timedOut: true }) killLocalChild(child).catch(() => undefined) @@ -273,8 +311,12 @@ async function runChildProcess( child.on('close', (code, signal) => { finish({ exitCode: code ?? 0, - stdout: decodeOutput(stdoutChunks), - stderr: decodeOutput(stderrChunks), + stdout: Buffer.concat(stdoutChunks) + .toString('utf8') + .replace(/\r\n/g, '\n'), + stderr: Buffer.concat(stderrChunks) + .toString('utf8') + .replace(/\r\n/g, '\n'), signal: signal ?? undefined, timedOut: false }) @@ -355,53 +397,3 @@ async function killLocalChild(child: ReturnType) { killer.on('close', () => resolve()) }) } - -function buildCmdBackgroundCommand(command: string, marker: string) { - return ( - [ - command, - 'set "__chatluna_code=%errorlevel%"', - 'echo.', - `echo ${marker}:%__chatluna_code%`, - 'exit /b %__chatluna_code%' - ].join('\r\n') + '\r\n' - ) -} - -function buildPowerShellBackgroundCommand(command: string, marker: string) { - return ( - [ - command, - '$__chatluna_code = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE }', - `Write-Output "\`n${marker}:$($__chatluna_code)"`, - 'exit $__chatluna_code' - ].join('\r\n') + '\r\n' - ) -} - -function decodeOutput(chunks: Buffer[]): string { - return Buffer.concat(chunks).toString('utf8').replace(/\r\n/g, '\n') -} - -function tmpdir(cfg: LocalBackendConfig) { - return path.join(cfg.scopePath || process.cwd(), '.tmp') -} - -function tmpEnv(tmp: string): NodeJS.ProcessEnv { - return { - TMP: tmp, - TEMP: tmp, - TMPDIR: tmp - } -} - -const CAPABILITIES: ComputerCapability[] = [ - 'file_read', - 'file_write', - 'file_edit', - 'file_publish', - 'grep', - 'glob', - 'bash', - 'terminal_pty' -] diff --git a/packages/extension-agent/src/computer/backends/local/sandbox.ts b/packages/extension-agent/src/computer/backends/local/sandbox.ts index 990bda3eb..b55e9720c 100644 --- a/packages/extension-agent/src/computer/backends/local/sandbox.ts +++ b/packages/extension-agent/src/computer/backends/local/sandbox.ts @@ -42,7 +42,13 @@ export function ensureLocalPathAccess( throw new Error(`Path "${filePath}" is denied by configuration.`) } - if (mode === 'write' && containsProtectedName(resolved)) { + if ( + mode === 'write' && + resolved + .replaceAll('\\', '/') + .split('/') + .some((s) => PROTECTED_NAMES.includes(s)) + ) { throw new Error( `Path "${filePath}" is protected and cannot be modified.` ) @@ -80,12 +86,17 @@ export function ensureLocalCommandAccess( if ( cfg.sandboxMode === 'read-only' && - WRITE_COMMAND_PATTERNS.some((item) => item.test(command)) + WRITE_COMMAND_PATTERNS.some((p) => p.test(command)) ) { throw new Error('Local backend is running in read-only mode.') } - if (containsProtectedName(command)) { + if ( + command + .replaceAll('\\', '/') + .split('/') + .some((s) => PROTECTED_NAMES.includes(s)) + ) { throw new Error('Command references a protected path.') } @@ -113,93 +124,65 @@ export function wrapCommandWithSandbox( ) } - const bwrapError = getBubblewrapError(bwrap, tmp) - if (bwrapError) { - throw new Error( - `Local backend sandbox requires bubblewrap, but the startup probe failed: ${bwrapError}` + if (!BWRAP_PROBE_CACHE.has(bwrap)) { + const result = spawnSync( + bwrap, + [ + '--ro-bind', + '/', + '/', + '--bind', + tmp, + '/tmp', + '--dev', + '/dev', + '--proc', + '/proc', + 'sh', + '-lc', + 'true' + ], + { encoding: 'utf8' } ) + + if (result.status === 0) { + BWRAP_PROBE_CACHE.add(bwrap) + } else { + const err = + result.stderr?.trim() || + result.error?.message || + 'bubblewrap startup probe failed.' + throw new Error( + `Local backend sandbox requires bubblewrap, but the startup probe failed: ${err}` + ) + } } + const ro = cfg.sandboxMode === 'read-only' const scope = cfg.scopePath || workdir || process.cwd() - const binds = - cfg.sandboxMode === 'read-only' - ? [`--ro-bind ${quote(scope)} ${quote(scope)}`] - : [`--bind ${quote(scope)} ${quote(scope)}`] - const temp = - cfg.sandboxMode === 'read-only' - ? [`--ro-bind ${quote(tmp)} /tmp`] - : [`--bind ${quote(tmp)} /tmp`] - const net = cfg.networkPolicy === 'block' ? ['--unshare-net'] : [] return [ quote(bwrap), '--ro-bind / /', - ...binds, - ...temp, + ro + ? `--ro-bind ${quote(scope)} ${quote(scope)}` + : `--bind ${quote(scope)} ${quote(scope)}`, + ro ? `--ro-bind ${quote(tmp)} /tmp` : `--bind ${quote(tmp)} /tmp`, '--dev /dev', '--proc /proc', '--die-with-parent', - ...net, + ...(cfg.networkPolicy === 'block' ? ['--unshare-net'] : []), 'sh -lc', quote(command) ].join(' ') } function isInsideRoot(target: string, root: string) { - const resolvedTarget = path.resolve(target) - const resolvedRoot = path.resolve(root) - return ( - resolvedTarget === resolvedRoot || - resolvedTarget.startsWith(resolvedRoot + path.sep) - ) -} - -function containsProtectedName(value: string) { - return value - .replaceAll('\\', '/') - .split('/') - .some((item) => PROTECTED_NAMES.includes(item)) -} - -function quote(value: string) { - return `'${value.replaceAll("'", `'\\''`)}'` + const t = path.resolve(target) + const r = path.resolve(root) + return t === r || t.startsWith(r + path.sep) } -function getBubblewrapError(bwrap: string, tmp: string) { - if (BWRAP_PROBE_CACHE.has(bwrap)) { - return '' - } - - const result = spawnSync( - bwrap, - [ - '--ro-bind', - '/', - '/', - '--bind', - tmp, - '/tmp', - '--dev', - '/dev', - '--proc', - '/proc', - 'sh', - '-lc', - 'true' - ], - { - encoding: 'utf8' - } - ) - - if (result.status === 0) { - BWRAP_PROBE_CACHE.add(bwrap) - return '' - } - - return ( - result.stderr?.trim() || - result.error?.message || - 'bubblewrap startup probe failed.' - ) +function quote(v: string) { + return `'${v.replaceAll("'", `'\\''`)}'` } diff --git a/packages/extension-agent/src/computer/backends/local/security.ts b/packages/extension-agent/src/computer/backends/local/security.ts index ba769384b..17a163335 100644 --- a/packages/extension-agent/src/computer/backends/local/security.ts +++ b/packages/extension-agent/src/computer/backends/local/security.ts @@ -1,6 +1,5 @@ import { randomBytes } from 'crypto' import path from 'path' -/** @module computer/backends/local/security */ import { Session } from 'koishi' import { LocalBackendConfig } from '../../../types' @@ -33,45 +32,32 @@ const HIGH_RISK_PATTERNS: RegExp[] = [ /\bdiskpart\b/i ] -export function isHighRisk(command: string) { - return HIGH_RISK_PATTERNS.some((pattern) => pattern.test(command)) -} - -export function ensureCommandAllowed(command: string, cfg: LocalBackendConfig) { - if (cfg.dangerouslySkipPermissions) { - return - } +export function ensureCommandAllowed(cmd: string, cfg: LocalBackendConfig) { + if (cfg.dangerouslySkipPermissions) return - const baseCmd = command.trim().split(/\s+/)[0]?.toLowerCase() - if (!baseCmd) { - throw new Error('Command is empty.') - } + const base = cmd.trim().split(/\s+/)[0]?.toLowerCase() + if (!base) throw new Error('Command is empty.') - if (cfg.blockedCommands.some((item) => baseCmd === item.toLowerCase())) { - throw new Error(`Command "${baseCmd}" is blocked by configuration.`) + if (cfg.blockedCommands.some((item) => base === item.toLowerCase())) { + throw new Error(`Command "${base}" is blocked by configuration.`) } if ( cfg.allowedCommands.length > 0 && - !cfg.allowedCommands.some((item) => baseCmd === item.toLowerCase()) + !cfg.allowedCommands.some((item) => base === item.toLowerCase()) ) { throw new Error( - `Command "${baseCmd}" is not in the allowed commands list.` + `Command "${base}" is not in the allowed commands list.` ) } } export function ensureWorkdirInScope(workdir: string, cfg: LocalBackendConfig) { - if (cfg.dangerouslySkipPermissions || !cfg.scopePath) { - return - } + if (cfg.dangerouslySkipPermissions || !cfg.scopePath) return - const resolvedWorkdir = path.resolve(workdir) - const resolvedScope = path.resolve(cfg.scopePath) - if ( - resolvedWorkdir !== resolvedScope && - !resolvedWorkdir.startsWith(resolvedScope + path.sep) - ) { + const resolved = path.resolve(workdir) + const scope = path.resolve(cfg.scopePath) + if (resolved !== scope && !resolved.startsWith(scope + path.sep)) { throw new Error( `Working directory "${workdir}" is outside the configured scope path "${cfg.scopePath}".` ) @@ -83,17 +69,15 @@ export function ensureCommandPathsInScope( cfg: LocalBackendConfig, isInScope: (filePath: string) => boolean ) { - if (cfg.dangerouslySkipPermissions || !cfg.scopePath) { - return - } + if (cfg.dangerouslySkipPermissions || !cfg.scopePath) return - const absolutePathPattern = + for (const match of command.matchAll( /(?:^|[\s="'`:(\[{;<>@,])((?:\/|[A-Za-z]:)[^\s"'`)\]}<>;,@]*)/g - for (const match of command.matchAll(absolutePathPattern)) { - const filePath = match[1] - if (!isInScope(path.resolve(filePath))) { + )) { + const fp = match[1] + if (!isInScope(path.resolve(fp))) { throw new Error( - `Command references path "${filePath}" which is outside the scope path "${cfg.scopePath}".` + `Command references path "${fp}" which is outside the scope path "${cfg.scopePath}".` ) } } @@ -107,7 +91,7 @@ export async function confirmHighRiskCommand( if ( cfg.dangerouslySkipPermissions || cfg.approvalMode === 'never' || - !isHighRisk(command) + !HIGH_RISK_PATTERNS.some((p) => p.test(command)) ) { return } @@ -122,8 +106,7 @@ export async function confirmHighRiskCommand( await session.send( `模型请求执行高危命令:\n\`${command}\`\n如需同意,请输入以下字符:${token}` ) - const reply = await session.prompt() - if (reply?.trim() !== token) { + if ((await session.prompt())?.trim() !== token) { throw new Error( 'Command execution cancelled: user did not confirm the high-risk operation.' ) diff --git a/packages/extension-agent/src/computer/backends/local/shell.ts b/packages/extension-agent/src/computer/backends/local/shell.ts index d8d5578c8..db3ee085e 100644 --- a/packages/extension-agent/src/computer/backends/local/shell.ts +++ b/packages/extension-agent/src/computer/backends/local/shell.ts @@ -11,6 +11,55 @@ export interface ResolvedShellCommand { env?: NodeJS.ProcessEnv } +function gitBashEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + CHERE_INVOKING: '1', + LANG: process.env['LANG'] || 'C.UTF-8', + LC_ALL: process.env['LC_ALL'] || 'C.UTF-8' + } +} + +function buildUtf8Env(): NodeJS.ProcessEnv { + return { + ...process.env, + PYTHONUTF8: '1', + PYTHONIOENCODING: 'utf-8' + } +} + +export function findPowerShell(): string | undefined { + return ( + which.sync('pwsh.exe', { nothrow: true }) ?? + which.sync('pwsh', { nothrow: true }) ?? + which.sync('powershell.exe', { nothrow: true }) + ) +} + +function resolvePowerShellCommand(command: string): ResolvedShellCommand { + return { + file: findPowerShell() ?? 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + [ + '$OutputEncoding = [System.Text.UTF8Encoding]::new($false)', + '[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)', + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)', + "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'", + "$PSDefaultParameterValues['*:Encoding'] = 'utf8'", + 'chcp.com 65001 > $null', + command + ].join('; ') + ], + env: buildUtf8Env() + } +} + export async function resolveInteractiveShellCommand( cfg: LocalBackendConfig ): Promise { @@ -37,18 +86,9 @@ export async function resolveInteractiveShellCommand( } } - const gitBashPath = await findGitBash() - if (gitBashPath) { - return { - file: gitBashPath, - args: ['-i'], - env: { - ...process.env, - CHERE_INVOKING: '1', - LANG: process.env['LANG'] || 'C.UTF-8', - LC_ALL: process.env['LC_ALL'] || 'C.UTF-8' - } - } + const gitBash = await findGitBash() + if (gitBash) { + return { file: gitBash, args: ['-i'], env: gitBashEnv() } } return { @@ -82,106 +122,33 @@ export async function resolveShellCommand( } if (cfg.preferredShell === 'git-bash') { - const gitBashPath = await findGitBash() - if (gitBashPath) { - return { - file: gitBashPath, - args: ['-lc', command], - env: { - ...process.env, - CHERE_INVOKING: '1', - LANG: process.env['LANG'] || 'C.UTF-8', - LC_ALL: process.env['LC_ALL'] || 'C.UTF-8' - } - } + const gitBash = await findGitBash() + if (gitBash) { + return { file: gitBash, args: ['-lc', command], env: gitBashEnv() } } } - const gitBashPath = await findGitBash() - if (gitBashPath) { - return { - file: gitBashPath, - args: ['-lc', command], - env: { - ...process.env, - CHERE_INVOKING: '1', - LANG: process.env['LANG'] || 'C.UTF-8', - LC_ALL: process.env['LC_ALL'] || 'C.UTF-8' - } - } + const gitBash = await findGitBash() + if (gitBash) { + return { file: gitBash, args: ['-lc', command], env: gitBashEnv() } } return resolvePowerShellCommand(command) } -function resolvePowerShellCommand(command: string): ResolvedShellCommand { - return { - file: findPowerShell() ?? 'powershell.exe', - args: [ - '-NoLogo', - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-Command', - buildWindowsPowerShellCommand(command) - ], - env: buildUtf8Env() - } -} - -function buildWindowsPowerShellCommand(command: string) { - return [ - '$OutputEncoding = [System.Text.UTF8Encoding]::new($false)', - '[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)', - '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)', - "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'", - "$PSDefaultParameterValues['*:Encoding'] = 'utf8'", - 'chcp.com 65001 > $null', - command - ].join('; ') -} - -export function findPowerShell(): string | undefined { - return ( - which.sync('pwsh.exe', { nothrow: true }) ?? - which.sync('pwsh', { nothrow: true }) ?? - which.sync('powershell.exe', { nothrow: true }) - ) -} - -function buildUtf8Env( - base: NodeJS.ProcessEnv = process.env -): NodeJS.ProcessEnv { - return { - ...base, - PYTHONUTF8: '1', - PYTHONIOENCODING: 'utf-8' - } -} - export async function findGitBash(): Promise { if (process.platform !== 'win32') { return null } - const exists = async (targetPath: string) => { - try { - await fs.access(targetPath) - return true - } catch { - return false - } - } - const roots = new Set() const gitPaths = [ which.sync('git.exe', { nothrow: true }), which.sync('git', { nothrow: true }) ].filter((item): item is string => item != null) - for (const gitPath of gitPaths) { - const dir = path.dirname(gitPath) + for (const p of gitPaths) { + const dir = path.dirname(p) roots.add(path.resolve(dir, '..')) roots.add(path.resolve(dir, '..', '..')) } @@ -199,11 +166,12 @@ export async function findGitBash(): Promise { } for (const root of roots) { - for (const relativePath of ['bin\\bash.exe', 'usr\\bin\\bash.exe']) { - const candidate = path.resolve(root, relativePath) - if (await exists(candidate)) { - return candidate - } + for (const rel of ['bin\\bash.exe', 'usr\\bin\\bash.exe']) { + const p = path.resolve(root, rel) + try { + await fs.access(p) + return p + } catch {} } } diff --git a/packages/extension-agent/src/computer/backends/local/store.ts b/packages/extension-agent/src/computer/backends/local/store.ts index 9dfb2104e..48720c067 100644 --- a/packages/extension-agent/src/computer/backends/local/store.ts +++ b/packages/extension-agent/src/computer/backends/local/store.ts @@ -51,8 +51,7 @@ export class FileStore implements BaseFileStore { const stat = await fs.stat(filePath) if (stat.isDirectory()) { - const entries = await fs.readdir(filePath, { withFileTypes: true }) - return entries + return (await fs.readdir(filePath, { withFileTypes: true })) .filter( (entry) => !this._shouldIgnore(path.join(filePath, entry.name)) @@ -65,16 +64,14 @@ export class FileStore implements BaseFileStore { .join('\n') } - const raw = await fs.readFile(filePath, 'utf-8') - const lines = raw.split('\n') + const lines = (await fs.readFile(filePath, 'utf-8')).split('\n') const start = offset != null ? Math.max(0, offset - 1) : 0 const end = limit != null ? Math.min(lines.length, start + limit) : lines.length const result = lines .slice(start, end) .map((line, idx) => { - const text = line.length > 2000 ? line.slice(0, 2000) : line - return `${start + idx + 1}: ${text}` + return `${start + idx + 1}: ${line.length > 2000 ? line.slice(0, 2000) : line}` }) .join('\n') @@ -99,9 +96,8 @@ export class FileStore implements BaseFileStore { replaceCount?: number ) { this.assertInScope(filePath) - const content = await fs.readFile(filePath, 'utf-8') const next = replaceSubstring( - content, + await fs.readFile(filePath, 'utf-8'), oldString, newString, replaceCount @@ -193,13 +189,16 @@ export class FileStore implements BaseFileStore { const text = ( (item.data.lines.text as string | undefined) || '' ).replace(/\r?\n$/, '') - const list = matched.get(file) || [] - list.push(`${file}:${item.data.line_number}:${text}`) - matched.set(file, list) + if (!matched.has(file)) { + matched.set(file, []) + } + matched + .get(file) + .push(`${file}:${item.data.line_number}:${text}`) } const files = await sortByMtime([...matched.keys()]) - return files.flatMap((file) => matched.get(file) || []) + return files.flatMap((f) => matched.get(f) || []) } catch (err) { if (process.env['CHATLUNA_AGENT_DEBUG']) { console.debug(err) @@ -240,8 +239,9 @@ export class FileStore implements BaseFileStore { } } - const sorted = await sortByMtime([...matched.keys()]) - return sorted.flatMap((file) => matched.get(file) || []) + return (await sortByMtime([...matched.keys()])).flatMap( + (f) => matched.get(f) || [] + ) } async glob(pattern: string, searchPath?: string) { @@ -253,7 +253,7 @@ export class FileStore implements BaseFileStore { return [] } - if (!stat?.isDirectory()) { + if (!stat.isDirectory()) { return this._matchPattern(dir, pattern) ? [dir] : [] } @@ -282,17 +282,17 @@ export class FileStore implements BaseFileStore { ) } - const files = result.stdout - .split('\0') - .filter(Boolean) - .map((file) => path.resolve(dir, file)) - .filter( - (file) => - !this._shouldIgnore(file) && - this._matchPattern(file, pattern) - ) - - return sortByMtime(files) + return sortByMtime( + result.stdout + .split('\0') + .filter(Boolean) + .map((file) => path.resolve(dir, file)) + .filter( + (file) => + !this._shouldIgnore(file) && + this._matchPattern(file, pattern) + ) + ) } catch (err) { if (process.env['CHATLUNA_AGENT_DEBUG']) { console.debug(err) @@ -364,14 +364,11 @@ export class FileStore implements BaseFileStore { } private _matchPattern(filePath: string, pattern: string) { - const base = this._cfg.scopePath || process.cwd() - const relativePath = path.relative(base, filePath).replaceAll('\\', '/') + const rel = path + .relative(this._cfg.scopePath || process.cwd(), filePath) + .replaceAll('\\', '/') return micromatch.some( - [ - relativePath, - filePath.replaceAll('\\', '/'), - path.basename(filePath) - ], + [rel, filePath.replaceAll('\\', '/'), path.basename(filePath)], pattern, { dot: true } ) @@ -381,12 +378,13 @@ export class FileStore implements BaseFileStore { if (this._cfg.ignores.length === 0) { return false } - - const base = this._cfg.scopePath || process.cwd() - const relativePath = path.relative(base, filePath).replace(/\\/g, '/') - return micromatch.isMatch(relativePath, this._cfg.ignores, { - dot: true - }) + return micromatch.isMatch( + path + .relative(this._cfg.scopePath || process.cwd(), filePath) + .replace(/\\/g, '/'), + this._cfg.ignores, + { dot: true } + ) } private assertInScope(filePath: string) { @@ -400,8 +398,8 @@ export class FileStore implements BaseFileStore { } } -async function runProcess(file: string, args: string[], cwd: string) { - return await new Promise<{ +function runProcess(file: string, args: string[], cwd: string) { + return new Promise<{ exitCode: number stdout: string stderr: string @@ -456,12 +454,12 @@ function replaceSubstring( } if (replaceCount === 1) { - const firstIdx = content.indexOf(oldString) - const secondIdx = content.indexOf( - oldString, - firstIdx + oldString.length - ) - if (secondIdx !== -1) { + if ( + content.indexOf( + oldString, + content.indexOf(oldString) + oldString.length + ) !== -1 + ) { throw new Error( 'Found multiple matches for oldString. Provide more surrounding ' + 'lines in oldString to identify the correct match, or set ' + @@ -489,8 +487,7 @@ function buildEditContext( newString: string ): string { const lines = content.split('\n') - const marker = newString || oldString - const row = lines.findIndex((line) => line.includes(marker)) + const row = lines.findIndex((line) => line.includes(newString || oldString)) const start = Math.max(0, row - 10) const end = Math.min(lines.length, row + 11) diff --git a/packages/extension-agent/src/computer/backends/open_terminal.ts b/packages/extension-agent/src/computer/backends/open_terminal.ts index 6a9d2446a..acf7dc3ea 100644 --- a/packages/extension-agent/src/computer/backends/open_terminal.ts +++ b/packages/extension-agent/src/computer/backends/open_terminal.ts @@ -8,7 +8,7 @@ import { Context } from 'koishi' import type {} from '@koishijs/plugin-proxy-agent' import mimeTypes from 'mime-types' import { quoteShell } from './types' -import { OpenTerminalBackendConfig } from '../../types' +import { ComputerCapability, OpenTerminalBackendConfig } from '../../types' import { ComputerSessionApi, ExecuteOptions, @@ -22,7 +22,16 @@ import { export class OpenTerminalComputerSession implements ComputerSessionApi { readonly backend = 'open-terminal' as const readonly sessionId: string - readonly capabilities = [...CAPABILITIES] + readonly capabilities = [ + 'file_read', + 'file_write', + 'file_publish', + 'file_edit', + 'grep', + 'glob', + 'bash', + 'terminal_pty' + ] as ComputerCapability[] private _connected = false private _home = '/' @@ -50,22 +59,22 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { throw new Error('open-terminal baseUrl is empty.') } - const current = readOpenTerminalData( - await this.ctx.http(this.url('/files/cwd'), { - method: 'GET', - proxyAgent: '', - headers: this.headers() - }) - ) - const root = current.cwd || '/' + const root = + readOpenTerminalData( + await this.ctx.http(this.url('/files/cwd'), { + method: 'GET', + proxyAgent: '', + headers: this.headers() + }) + ).cwd || '/' this._home = root - const home = await this.execute('printf %s "$HOME"', { + const homeResult = await this.execute('printf %s "$HOME"', { workdir: root, timeout: 5000 }).catch(() => undefined) - if (home?.stdout?.startsWith('/')) { - this._home = home.stdout.trim() + if (homeResult?.stdout?.startsWith('/')) { + this._home = homeResult.stdout.trim() } if (this.options.cwd) { @@ -121,9 +130,8 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { }) ) const dir = result.dir || target - const entries = Array.isArray(result.entries) ? result.entries : [] - return entries + return (Array.isArray(result.entries) ? result.entries : []) .map((item) => { if (typeof item?.name !== 'string') { return undefined @@ -173,25 +181,25 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { const start = offset ?? 1 const lines = text.split('\n') - const resultLines = lines.map((line, idx) => `${start + idx}: ${line}`) const total = typeof result.total_lines === 'number' ? result.total_lines : start + lines.length - 1 + const numbered = lines.map((line, idx) => `${start + idx}: ${line}`) + if (start + lines.length - 1 >= total) { - return resultLines.join('\n') + return numbered.join('\n') } - return `${resultLines.join('\n')}\n\n(Showing lines ${start}-${start + lines.length - 1} of ${total}. Use offset=${start + lines.length} to continue.)` + return `${numbered.join('\n')}\n\n(Showing lines ${start}-${start + lines.length - 1} of ${total}. Use offset=${start + lines.length} to continue.)` } async writeFile(filePath: string, content: FileContent) { if (typeof content !== 'string') { const target = this.resolvePath(filePath) - const dir = posix.dirname(target) const tmp = `${target}.${randomUUID()}.base64` - await this.execute(`mkdir -p ${quoteShell(dir)}`) + await this.execute(`mkdir -p ${quoteShell(posix.dirname(target))}`) await this.writeFile(tmp, Buffer.from(content).toString('base64')) try { @@ -240,12 +248,12 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { } if (replaceCount === 1) { - const firstIdx = content.indexOf(oldString) - const secondIdx = content.indexOf( - oldString, - firstIdx + oldString.length - ) - if (secondIdx !== -1) { + if ( + content.indexOf( + oldString, + content.indexOf(oldString) + oldString.length + ) !== -1 + ) { throw new Error( `Found multiple matches for oldString in ${filePath}. ` + 'Provide more surrounding lines in oldString to identify the correct match, or set replaceAll to change every instance.' @@ -286,13 +294,12 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { const lines = next.split('\n') const row = lines.findIndex((line) => line.includes(newString)) const start = Math.max(0, row - 10) - const end = Math.min(lines.length, row + 11) return { success: true, replacements, context: lines - .slice(start, end) + .slice(start, Math.min(lines.length, row + 11)) .map( (line, idx) => `${start + idx + 1 === row + 1 ? '>' : ' '} ${start + idx + 1}: ${line}` @@ -323,9 +330,7 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { }) ) - const matches = Array.isArray(result.matches) ? result.matches : [] - - return matches + return (Array.isArray(result.matches) ? result.matches : []) .map((item) => { if (typeof item?.file !== 'string') { return undefined @@ -356,9 +361,7 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { }) ) - const matches = Array.isArray(result.matches) ? result.matches : [] - - return matches + return (Array.isArray(result.matches) ? result.matches : []) .map((item) => { if (typeof item?.path !== 'string') { return undefined @@ -386,12 +389,15 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { const end = `__CHATLUNA_OPEN_TERMINAL_END__${id}` const stdoutPath = `/tmp/chatluna-${id}.stdout` const stderrPath = `/tmp/chatluna-${id}.stderr` - const env = options.env - ? Object.entries(options.env) - .map(([key, value]) => `export ${key}=${quoteShell(value)}`) - .join('\n') - : '' - const wrapped = `${env ? `${env}\n` : ''}stty -echo 2>/dev/null + const wrapped = `${ + options.env + ? Object.entries(options.env) + .map( + ([key, value]) => `export ${key}=${quoteShell(value)}` + ) + .join('\n') + '\n' + : '' + }stty -echo 2>/dev/null export PS1='' __chatluna_stdout=${quoteShell(stdoutPath)} __chatluna_stderr=${quoteShell(stderrPath)} @@ -518,8 +524,12 @@ exit } async readAsset(filePath: string) { - const asset = await this.openAsset(filePath) - return (await readOpenTerminalAsset(asset.stream)).toString('base64') + const { stream } = await this.openAsset(filePath) + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString('base64') } async openAsset(filePath: string) { @@ -537,13 +547,14 @@ exit throw new Error(`Failed to open asset: ${result.status}`) } - const mimeType = result.headers.get('content-type') - const fallback = mimeTypes.lookup(target) const size = Number(result.headers.get('content-length') ?? '') + const fallback = mimeTypes.lookup(target) return { stream: Readable.from(Buffer.from(result.data)), size: Number.isFinite(size) ? size : undefined, - mimeType: mimeType ?? (fallback === false ? undefined : fallback) + mimeType: + result.headers.get('content-type') ?? + (fallback === false ? undefined : fallback) } } @@ -673,9 +684,9 @@ exit } const headers: Record = {} - const apiKey = this.resolveSecret(this.cfg.apiKey) - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}` + const key = this.resolveSecret(this.cfg.apiKey) + if (key) { + headers.Authorization = `Bearer ${key}` } if (this.options.userId) { headers['X-User-Id'] = this.options.userId @@ -686,9 +697,10 @@ exit } private url(pathname: string) { + const base = this.cfg.baseUrl return new URL( pathname, - ensureTrailingSlash(this.cfg.baseUrl) + base.endsWith('/') ? base : `${base}/` ).toString() } @@ -842,18 +854,8 @@ async function openOpenTerminal( } satisfies OpenTerminalSocket } -function ensureTrailingSlash(url: string) { - return url.endsWith('/') ? url : `${url}/` -} - function toWebSocketUrl(url: string) { - if (url.startsWith('https://')) { - return `wss://${url.slice('https://'.length)}` - } - if (url.startsWith('http://')) { - return `ws://${url.slice('http://'.length)}` - } - return url + return url.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://') } function joinPath(dir: string, path: string) { @@ -907,22 +909,3 @@ async function readOpenTerminalMessage(value: unknown) { function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } - -async function readOpenTerminalAsset(stream: Readable) { - const chunks: Buffer[] = [] - for await (const chunk of stream) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - } - return Buffer.concat(chunks) -} - -const CAPABILITIES = [ - 'file_read', - 'file_write', - 'file_publish', - 'file_edit', - 'grep', - 'glob', - 'bash', - 'terminal_pty' -] as const diff --git a/packages/extension-agent/src/computer/backends/types.ts b/packages/extension-agent/src/computer/backends/types.ts index 4a98865df..a14f0f5c3 100644 --- a/packages/extension-agent/src/computer/backends/types.ts +++ b/packages/extension-agent/src/computer/backends/types.ts @@ -1,8 +1,3 @@ -/** - * @module computer/backends/types - * @description Computer backend 共享工具。 - */ - import type { ExecuteResult } from '../types' /** Shell 单引号转义。 */ @@ -12,25 +7,15 @@ export function quoteShell(value: string): string { /** 截断输出文本。 */ export function truncateOutput(text: string, limit: number): string { - if (text.length <= limit) { - return text - } - + if (text.length <= limit) return text return `${text.slice(0, limit)}\n...[output truncated]` } /** 标准化 execute 输出为单个文本块。 */ export function formatExecuteResult(result: ExecuteResult): string { const parts: string[] = [] - - if (result.stdout) { - parts.push(result.stdout) - } - - if (result.stderr) { - parts.push(`[stderr]\n${result.stderr}`) - } - + if (result.stdout) parts.push(result.stdout) + if (result.stderr) parts.push(`[stderr]\n${result.stderr}`) return parts.join('\n') || '(no output)' } diff --git a/packages/extension-agent/src/computer/background.ts b/packages/extension-agent/src/computer/background.ts index 06e3dc716..56062a29b 100644 --- a/packages/extension-agent/src/computer/background.ts +++ b/packages/extension-agent/src/computer/background.ts @@ -55,11 +55,8 @@ export function toBackgroundJobInfo( export function appendBackgroundOutput(current: string, data: string) { const next = current + data - if (next.length <= 16000) { - return next - } - - return next.slice(next.length - 16000) + if (next.length <= 16000) return next + return next.slice(-16000) } export function readBackgroundExit( @@ -67,14 +64,11 @@ export function readBackgroundExit( data: string, marker: string ) { - const text = `${pending}${data}` - const lines = text.split(/\r?\n/) - const rest = lines.pop() ?? '' + const lines = (pending + data).split(/\r?\n/) + const rest = lines.pop() for (const line of lines) { - if (!line.startsWith(`${marker}:`)) { - continue - } + if (!line.startsWith(`${marker}:`)) continue const code = Number(line.slice(marker.length + 1)) return { @@ -87,8 +81,8 @@ export function readBackgroundExit( } export function stripBackgroundMarker(output: string, marker: string) { - const lines = output.split('\n') - return lines + return output + .split('\n') .filter((line) => !line.includes(`${marker}:`)) .join('\n') .replace(/\n+$/, '\n') diff --git a/packages/extension-agent/src/computer/materialize.ts b/packages/extension-agent/src/computer/materialize.ts index e3096d426..d42c6ba7c 100644 --- a/packages/extension-agent/src/computer/materialize.ts +++ b/packages/extension-agent/src/computer/materialize.ts @@ -24,14 +24,8 @@ export class SkillMaterializer { } getPath(skill: ScannedSkill, session: ComputerSessionApi) { - if (session.backend === 'local') { - return skill.dir - } - - if (skill.remote) { - return skill.dir - } - + if (session.backend === 'local') return skill.dir + if (skill.remote) return skill.dir return getRemoteSkillDir(skill.name) } @@ -44,7 +38,9 @@ export class SkillMaterializer { if (session.backend === 'local') { if (skill.name === AGENTCLI_SKILL_NAME && ctx) { const target = path.join(root, 'config.json') - if (!(await fileExists(target))) { + try { + await access(target) + } catch { await writeFile(target, await readHostConfigBytes(ctx)) } } @@ -60,19 +56,27 @@ export class SkillMaterializer { } const current = this._items.get(session.sessionId)?.get(skill.id) - if (current) { - return current + if (current) return current + + const quoted = quoteShellPath(root) + const result = await session.execute( + `if [ -d ${quoted} ]; then rm -rf ${quoted}; elif [ -e ${quoted} ]; then rm -f ${quoted}; fi`, + { timeout: 15000 } + ) + if (result.exitCode !== 0) { + throw new Error( + result.stderr.trim() || + result.stdout.trim() || + `Failed to reset remote skill dir: ${root}` + ) } - await resetRemoteSkillDir(root, session) const files = await listSkillResources(skill.dir) - await session.writeFile(posix.join(root, 'SKILL.md'), skill.raw) for (const file of files) { - const hostPath = path.join(skill.dir, file) - const data = await readFile(hostPath) + const data = await readFile(path.join(skill.dir, file)) await session.writeFile( - posix.join(root, normalizeRemotePath(file)), + posix.join(root, file.replaceAll('\\', '/')), data ) } @@ -118,46 +122,13 @@ export function getRemoteSkillDir(name: string) { return posix.join(REMOTE_SKILLS_ROOT, name) } -async function resetRemoteSkillDir(root: string, session: ComputerSessionApi) { - const quoted = quoteShellPath(root) - const result = await session.execute( - `if [ -d ${quoted} ]; then rm -rf ${quoted}; elif [ -e ${quoted} ]; then rm -f ${quoted}; fi`, - { - timeout: 15000 - } - ) - - if (result.exitCode !== 0) { - throw new Error( - result.stderr.trim() || - result.stdout.trim() || - `Failed to reset remote skill dir: ${root}` - ) - } -} - -function normalizeRemotePath(value: string) { - return value.replaceAll('\\', '/') -} - -async function fileExists(p: string) { - try { - await access(p) - return true - } catch { - return false - } -} - async function readHostConfigBytes(ctx: Context) { - const configPath = getConfigPath(ctx) try { - return await readFile(configPath) + return await readFile(getConfigPath(ctx)) } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return Buffer.from('{}\n', 'utf-8') } - throw err } } diff --git a/packages/extension-agent/src/computer/proxy.ts b/packages/extension-agent/src/computer/proxy.ts index b92f37f57..983d733e5 100644 --- a/packages/extension-agent/src/computer/proxy.ts +++ b/packages/extension-agent/src/computer/proxy.ts @@ -1,5 +1,3 @@ -/** @module computer/proxy */ - import { IncomingMessage } from 'http' import { WebSocket } from 'ws' import { Context } from 'koishi' @@ -39,22 +37,18 @@ export class ChatLunaAgentComputerProxy { private async acceptTerminal(socket: WebSocket, request: IncomingMessage) { const url = new URL(request.url ?? '/', 'http://127.0.0.1') - const match = url.pathname.match( - /^\/chatluna\/computer\/terminal\/([^/]+)\/([^/]+)$/ - ) - if (!match) { - socket.close() - return - } + const seg = url.pathname.split('/') + const sid = seg[4] + const tid = seg[5] - const item = this.service.getTerminal(match[1], match[2]) + const item = this.service.getTerminal(sid, tid) if (!item || url.searchParams.get('token') !== item.token) { socket.close() return } const terminal = item.terminal - this.service.touchSession(match[1]) + this.service.touchSession(sid) const offData = await terminal.onData((data) => { if (socket.readyState === socket.OPEN) { @@ -67,19 +61,19 @@ export class ChatLunaAgentComputerProxy { ? chunk.toString('utf8') : String(chunk) try { - const data = JSON.parse(text) - if (data.type === 'input') { - terminal.sendInput(String(data.data ?? '')) + const msg = JSON.parse(text) + if (msg.type === 'input') { + terminal.sendInput(String(msg.data ?? '')) return } - if (data.type === 'resize') { + if (msg.type === 'resize') { terminal.resize( - Number(data.cols) || 80, - Number(data.rows) || 24 + Number(msg.cols) || 80, + Number(msg.rows) || 24 ) return } - if (data.type === 'kill') { + if (msg.type === 'kill') { terminal.kill() } } catch { @@ -89,9 +83,7 @@ export class ChatLunaAgentComputerProxy { socket.on('close', () => { offData() - this.service - .handleTerminalSocketClose(match[1], match[2]) - .catch(() => {}) + this.service.handleTerminalSocketClose(sid, tid).catch(() => {}) }) } } diff --git a/packages/extension-agent/src/computer/session.ts b/packages/extension-agent/src/computer/session.ts index a36c774bb..6b0105e46 100644 --- a/packages/extension-agent/src/computer/session.ts +++ b/packages/extension-agent/src/computer/session.ts @@ -26,16 +26,12 @@ export class ComputerSessionStore { return this._items.get(key)?.session } - getBySessionId(sessionId: string) { - return Array.from(this._items.values()).find( - (item) => item.info.id === sessionId - )?.session + getBySessionId(id: string) { + return this._findBySessionId(id)?.session } - getInfoBySessionId(sessionId: string) { - return Array.from(this._items.values()).find( - (item) => item.info.id === sessionId - )?.info + getInfoBySessionId(id: string) { + return this._findBySessionId(id)?.info } async getOrCreate( @@ -89,71 +85,48 @@ export class ComputerSessionStore { return task } - touchBySessionId(sessionId: string) { - const item = Array.from(this._items.values()).find( - (item) => item.info.id === sessionId - ) - if (!item) { - return - } - + touchBySessionId(id: string) { + const item = this._findBySessionId(id) + if (!item) return item.info.lastActiveAt = Date.now() item.info.cwd = item.session.cwd } - enterBySessionId(sessionId: string) { - const item = Array.from(this._items.values()).find( - (item) => item.info.id === sessionId - ) - if (!item) { - return - } + enterBySessionId(id: string) { + const item = this._findBySessionId(id) + if (!item) return item.active += 1 item.info.lastActiveAt = Date.now() item.info.cwd = item.session.cwd } - leaveBySessionId(sessionId: string) { - const item = Array.from(this._items.values()).find( - (item) => item.info.id === sessionId - ) - if (!item) { - return - } + leaveBySessionId(id: string) { + const item = this._findBySessionId(id) + if (!item) return item.active = Math.max(0, item.active - 1) item.info.lastActiveAt = Date.now() item.info.cwd = item.session.cwd } - isBusy(sessionId: string) { - return ( - (Array.from(this._items.values()).find( - (item) => item.info.id === sessionId - )?.active ?? 0) > 0 - ) + isBusy(id: string) { + return (this._findBySessionId(id)?.active ?? 0) > 0 } async destroy(key: string) { const item = this._items.get(key) - if (!item) { - return - } + if (!item) return this._items.delete(key) await item.session.disconnect() } - async destroyBySessionId(sessionId: string) { + async destroyBySessionId(id: string) { const entry = Array.from(this._items.entries()).find( - ([, item]) => item.info.id === sessionId + ([, item]) => item.info.id === id ) - if (!entry) { - return - } - - await this.destroy(entry[0]) + if (entry) await this.destroy(entry[0]) } async clear() { @@ -161,13 +134,17 @@ export class ComputerSessionStore { this._items.clear() await Promise.all(items.map((item) => item.session.disconnect())) } + + private _findBySessionId(id: string) { + for (const item of this._items.values()) { + if (item.info.id === id) return item + } + return undefined + } } -export function buildComputerSessionKey(options: ComputerSessionKeyOptions) { - return [ - options.backend, - options.conversationId ?? options.userId ?? randomUUID() - ].join(':') +export function buildComputerSessionKey(opts: ComputerSessionKeyOptions) { + return `${opts.backend}:${opts.conversationId ?? opts.userId ?? randomUUID()}` } export interface ComputerSessionKeyOptions { diff --git a/packages/extension-agent/src/computer/tools/base.ts b/packages/extension-agent/src/computer/tools/base.ts index f4eb9e14e..3cfac7e35 100644 --- a/packages/extension-agent/src/computer/tools/base.ts +++ b/packages/extension-agent/src/computer/tools/base.ts @@ -15,8 +15,8 @@ export abstract class ComputerToolBase extends StructuredTool { } /** 获取当前 tool 对应的 computer session。 */ - protected async getSession(runConfig?: ChatLunaToolRunnable) { - return await this.computer.getToolSession(runConfig) + protected getSession(runConfig?: ChatLunaToolRunnable) { + return this.computer.getToolSession(runConfig) } protected log(session: ComputerSessionApi, message: string) { diff --git a/packages/extension-agent/src/computer/tools/bash.ts b/packages/extension-agent/src/computer/tools/bash.ts index d9b26f331..7175120e2 100644 --- a/packages/extension-agent/src/computer/tools/bash.ts +++ b/packages/extension-agent/src/computer/tools/bash.ts @@ -101,7 +101,6 @@ When to use: _runManager: unknown, toolConfig: ChatLunaToolRunnable ) { - const session = toolConfig?.configurable?.session const action = input.action ?? (input.jobId ? 'status' : 'run') try { @@ -117,7 +116,12 @@ When to use: : 'No background jobs.' } - return formatJobList(jobs) + return jobs + .map( + (job) => + `${job.id} [${job.state}] ${job.backend} timeout=${job.timeout == null ? 'none' : `${job.timeout}ms`} ${job.command}` + ) + .join('\n') } if (action === 'status') { @@ -153,23 +157,32 @@ When to use: } const raw = input.command?.trim() - const backgroundCommand = raw - ? stripBackgroundSuffix(raw) - : undefined - const background = - input.background === true || backgroundCommand != null - const command = backgroundCommand ?? raw ?? '' + const bgCmd = + raw && raw.endsWith('&') && !raw.endsWith('&&') + ? raw.slice(0, -1).trimEnd() + : undefined + const command = bgCmd ?? raw ?? '' this.log(computer, `执行命令: \`${command}\``) - if (background) { + if (input.background === true || bgCmd != null) { const job = await this.computer.runBackgroundCommand(command, { runConfig: toolConfig, workdir: input.workdir, timeout: input.timeout }) - return formatJobStart(job) + return [ + `Background job started: ${job.id}`, + `State: ${job.state}`, + `Backend: ${job.backend}`, + `Terminal: ${job.sessionId}/${job.terminalId}`, + `Working directory: ${job.cwd}`, + `Timeout: ${job.timeout == null ? 'none' : `${job.timeout}ms`}`, + `Command: ${job.command}`, + `Terminal URL: ${job.url}`, + `Use bash with {"action":"status","jobId":"${job.id}"} to inspect it or {"action":"list","state":"running"} to query running jobs.` + ].join('\n') } const timeout = @@ -178,7 +191,7 @@ When to use: const result = await computer.execute(command, { workdir: input.workdir, timeout, - session + session: toolConfig?.configurable?.session }) if (result.timedOut) { @@ -225,38 +238,6 @@ When to use: } } -function stripBackgroundSuffix(command: string) { - const trimmed = command.trimEnd() - if (!trimmed.endsWith('&') || trimmed.endsWith('&&')) { - return undefined - } - - return trimmed.slice(0, -1).trimEnd() -} - -function formatJobStart(job: ComputerBackgroundJobInfo) { - return [ - `Background job started: ${job.id}`, - `State: ${job.state}`, - `Backend: ${job.backend}`, - `Terminal: ${job.sessionId}/${job.terminalId}`, - `Working directory: ${job.cwd}`, - `Timeout: ${job.timeout == null ? 'none' : `${job.timeout}ms`}`, - `Command: ${job.command}`, - `Terminal URL: ${job.url}`, - `Use bash with {"action":"status","jobId":"${job.id}"} to inspect it or {"action":"list","state":"running"} to query running jobs.` - ].join('\n') -} - -function formatJobList(jobs: ComputerBackgroundJobInfo[]) { - return jobs - .map( - (job) => - `${job.id} [${job.state}] ${job.backend} timeout=${job.timeout == null ? 'none' : `${job.timeout}ms`} ${job.command}` - ) - .join('\n') -} - function formatJobDetail(job: ComputerBackgroundJobInfo) { const lines = [ `Job: ${job.id}`, diff --git a/packages/extension-agent/src/computer/tools/file_edit.ts b/packages/extension-agent/src/computer/tools/file_edit.ts index d33b3b873..d61c94f57 100644 --- a/packages/extension-agent/src/computer/tools/file_edit.ts +++ b/packages/extension-agent/src/computer/tools/file_edit.ts @@ -5,9 +5,6 @@ import z from 'zod' import { getErrorMessage } from '../../utils/shell' import { ComputerToolBase } from './base' -const MSG_EDITING = '编辑文件' -const MSG_DONE = '完成编辑' - export class EditFileTool extends ComputerToolBase { name = 'file_edit' @@ -42,7 +39,7 @@ Usage: ) { const computer = await this.getSession(toolConfig) - this.log(computer, `${MSG_EDITING}: ${input.filePath}`) + this.log(computer, `编辑文件: ${input.filePath}`) try { const result = await computer.editFile( @@ -58,7 +55,7 @@ Usage: this.log( computer, - `${MSG_DONE}: ${input.filePath} (替换 ${result.replacements} 处)` + `完成编辑: ${input.filePath} (替换 ${result.replacements} 处)` ) return this.withBackend( computer, diff --git a/packages/extension-agent/src/computer/tools/file_read.ts b/packages/extension-agent/src/computer/tools/file_read.ts index 8b9a69caa..3347d406b 100644 --- a/packages/extension-agent/src/computer/tools/file_read.ts +++ b/packages/extension-agent/src/computer/tools/file_read.ts @@ -5,9 +5,6 @@ import z from 'zod' import { getErrorMessage } from '../../utils/shell' import { ComputerToolBase } from './base' -const MSG_READING = '读取文件' -const MSG_DONE = '完成读取' - export class ReadFileTool extends ComputerToolBase { name = 'file_read' @@ -41,7 +38,7 @@ Usage: ) { const computer = await this.getSession(toolConfig) - this.log(computer, `${MSG_READING}: ${input.filePath}`) + this.log(computer, `读取文件: ${input.filePath}`) try { const result = await computer.readFile( @@ -51,7 +48,7 @@ Usage: ) this.log( computer, - `${MSG_DONE}: ${input.filePath} (${result.split('\n').length} 行)` + `完成读取: ${input.filePath} (${result.split('\n').length} 行)` ) return this.withBackend( computer, diff --git a/packages/extension-agent/src/computer/tools/file_write.ts b/packages/extension-agent/src/computer/tools/file_write.ts index a556be246..3cb267522 100644 --- a/packages/extension-agent/src/computer/tools/file_write.ts +++ b/packages/extension-agent/src/computer/tools/file_write.ts @@ -5,9 +5,6 @@ import z from 'zod' import { getErrorMessage } from '../../utils/shell' import { ComputerToolBase } from './base' -const MSG_WRITING = '写入文件' -const MSG_DONE = '完成写入' - export class WriteFileTool extends ComputerToolBase { name = 'file_write' @@ -34,11 +31,11 @@ Usage: ) { const computer = await this.getSession(toolConfig) - this.log(computer, `${MSG_WRITING}: ${input.filePath}`) + this.log(computer, `写入文件: ${input.filePath}`) try { await computer.writeFile(input.filePath, input.content) - this.log(computer, `${MSG_DONE}: ${input.filePath}`) + this.log(computer, `完成写入: ${input.filePath}`) return this.withBackend( computer, this.formatResult(true, `Wrote ${input.filePath}`) diff --git a/packages/extension-agent/src/computer/tools/glob.ts b/packages/extension-agent/src/computer/tools/glob.ts index 69c3e8333..a34a989cc 100644 --- a/packages/extension-agent/src/computer/tools/glob.ts +++ b/packages/extension-agent/src/computer/tools/glob.ts @@ -5,9 +5,6 @@ import z from 'zod' import { getErrorMessage } from '../../utils/shell' import { ComputerToolBase } from './base' -const MSG_FINDING = '查找文件' -const MSG_FOUND = '找到' - export class GlobTool extends ComputerToolBase { name = 'glob' @@ -34,7 +31,7 @@ export class GlobTool extends ComputerToolBase { this.log( computer, - `${MSG_FINDING}: ${input.pattern}${input.path ? ` in ${input.path}` : ''}` + `查找文件: ${input.pattern}${input.path ? ` in ${input.path}` : ''}` ) try { @@ -43,7 +40,7 @@ export class GlobTool extends ComputerToolBase { return 'No files matched.' } - this.log(computer, `${MSG_FOUND} ${results.length} 个文件`) + this.log(computer, `找到 ${results.length} 个文件`) return this.withBackend( computer, await this.formatLargeResult( diff --git a/packages/extension-agent/src/computer/tools/grep.ts b/packages/extension-agent/src/computer/tools/grep.ts index 4ae224d75..0372f5927 100644 --- a/packages/extension-agent/src/computer/tools/grep.ts +++ b/packages/extension-agent/src/computer/tools/grep.ts @@ -5,9 +5,6 @@ import z from 'zod' import { getErrorMessage } from '../../utils/shell' import { ComputerToolBase } from './base' -const MSG_SEARCHING = '搜索' -const MSG_FOUND = '找到' - export class GrepTool extends ComputerToolBase { name = 'grep' @@ -44,7 +41,7 @@ export class GrepTool extends ComputerToolBase { this.log( computer, - `${MSG_SEARCHING}: ${input.pattern}${input.include ? ` (${input.include})` : ''}${input.path ? ` in ${input.path}` : ''}` + `搜索: ${input.pattern}${input.include ? ` (${input.include})` : ''}${input.path ? ` in ${input.path}` : ''}` ) try { @@ -57,7 +54,7 @@ export class GrepTool extends ComputerToolBase { return 'No matches found.' } - this.log(computer, `${MSG_FOUND} ${results.length} 条匹配`) + this.log(computer, `找到 ${results.length} 条匹配`) return this.withBackend( computer, await this.formatLargeResult( diff --git a/packages/extension-agent/src/computer/tools/publish_file.ts b/packages/extension-agent/src/computer/tools/publish_file.ts index 676365eaa..04f0c18a4 100644 --- a/packages/extension-agent/src/computer/tools/publish_file.ts +++ b/packages/extension-agent/src/computer/tools/publish_file.ts @@ -5,8 +5,6 @@ import z from 'zod' import { getErrorMessage } from '../../utils/shell' import { ComputerToolBase } from './base' -const MSG_PUBLISHING = '发布文件' - export class PublishFileTool extends ComputerToolBase { name = 'file_publish' @@ -29,7 +27,7 @@ Usage: toolConfig: ChatLunaToolRunnable ) { const computer = await this.getSession(toolConfig) - this.log(computer, `${MSG_PUBLISHING}: ${input.paths.join(', ')}`) + this.log(computer, `发布文件: ${input.paths.join(', ')}`) try { const files = await this.computer.publishFile( diff --git a/packages/extension-agent/src/config/defaults.ts b/packages/extension-agent/src/config/defaults.ts index 993763194..4c6fd682c 100644 --- a/packages/extension-agent/src/config/defaults.ts +++ b/packages/extension-agent/src/config/defaults.ts @@ -8,36 +8,32 @@ import { SkillConfig, SubAgentConfig, SubAgentItemConfig, + ToolCharacterScope, ToolConfig, ToolItemConfig, + ToolMetaOverride, TriggerConfig } from '../types' import { DEFAULT_SKILL_DIRS } from './path' -export function getDefaultToolAuthority(name?: string) { - if ( - name === 'bash' || - name === 'file_edit' || - name === 'file_read' || - name === 'file_write' || - name === 'glob' || - name === 'grep' || - name === 'trigger' - ) { - return 3 - } +const HIGH_AUTHORITY_TOOLS = new Set([ + 'bash', + 'file_edit', + 'file_read', + 'file_write', + 'glob', + 'grep', + 'trigger' +]) - return 0 +export function getDefaultToolAuthority(name?: string) { + return name && HIGH_AUTHORITY_TOOLS.has(name) ? 3 : 0 } export function createPermissionRule( mode: PermissionRule['mode'] = 'inherit' ): PermissionRule { - return { - mode, - allow: [], - deny: [] - } + return { mode, allow: [], deny: [] } } function copyRule(rule?: PermissionRule, mode: PermissionRule['mode'] = 'all') { @@ -48,6 +44,10 @@ function copyRule(rule?: PermissionRule, mode: PermissionRule['mode'] = 'all') { } } +function normalizeMode(value?: string): 'all' | 'allow' | 'deny' { + return value === 'allow' || value === 'deny' ? value : 'all' +} + export function createSubAgentItemConfig( input: Partial = {} ): SubAgentItemConfig { @@ -59,16 +59,8 @@ export function createSubAgentItemConfig( character: input.character !== false, characterGroup: input.characterGroup !== false, characterPrivate: input.characterPrivate !== false, - characterGroupMode: - input.characterGroupMode === 'allow' || - input.characterGroupMode === 'deny' - ? input.characterGroupMode - : 'all', - characterPrivateMode: - input.characterPrivateMode === 'allow' || - input.characterPrivateMode === 'deny' - ? input.characterPrivateMode - : 'all', + characterGroupMode: normalizeMode(input.characterGroupMode), + characterPrivateMode: normalizeMode(input.characterPrivateMode), characterGroupIds: [...(input.characterGroupIds ?? [])], characterPrivateIds: [...(input.characterPrivateIds ?? [])], authority: input.authority ?? 0, @@ -100,16 +92,8 @@ export function createToolItemConfig( character: input.character !== false, characterGroup: input.characterGroup !== false, characterPrivate: input.characterPrivate !== false, - characterGroupMode: - input.characterGroupMode === 'allow' || - input.characterGroupMode === 'deny' - ? input.characterGroupMode - : 'all', - characterPrivateMode: - input.characterPrivateMode === 'allow' || - input.characterPrivateMode === 'deny' - ? input.characterPrivateMode - : 'all', + characterGroupMode: normalizeMode(input.characterGroupMode), + characterPrivateMode: normalizeMode(input.characterPrivateMode), characterGroupIds: [...(input.characterGroupIds ?? [])], characterPrivateIds: [...(input.characterPrivateIds ?? [])], subAgents: copyRule(input.subAgents, 'all'), @@ -133,357 +117,111 @@ export function createSkillItemConfig( character: input.character !== false, characterGroup: input.characterGroup !== false, characterPrivate: input.characterPrivate !== false, - characterGroupMode: - input.characterGroupMode === 'allow' || - input.characterGroupMode === 'deny' - ? input.characterGroupMode - : 'all', - characterPrivateMode: - input.characterPrivateMode === 'allow' || - input.characterPrivateMode === 'deny' - ? input.characterPrivateMode - : 'all', + characterGroupMode: normalizeMode(input.characterGroupMode), + characterPrivateMode: normalizeMode(input.characterPrivateMode), characterGroupIds: [...(input.characterGroupIds ?? [])], characterPrivateIds: [...(input.characterPrivateIds ?? [])], subAgents: copyRule(input.subAgents, 'all') } } +// -- Tool registry helpers -- + +function toolEntry( + source: string, + group: string, + tags: string[], + scope: ToolCharacterScope = 'all', + enabled = true +): ToolMetaOverride { + return createToolMetaOverride({ + source, + group, + tags, + defaultAvailability: { + enabled, + main: enabled, + chatluna: enabled, + characterScope: scope + } + }) +} + +function builtinEntry(scope: ToolCharacterScope = 'all'): ToolMetaOverride { + return createToolMetaOverride({ + defaultAvailability: { + enabled: true, + main: true, + chatluna: true, + characterScope: scope + } + }) +} + +function buildBrowserRegistry(): Record { + const web = ['browser', 'web'] + const debug = ['browser', 'web', 'debug'] + const input = ['browser', 'web', 'input'] + + const entries: [string, string[]][] = [ + ['browser_open', web], + ['browser_list_pages', web], + ['browser_select_page', web], + ['browser_close_page', web], + ['browser_navigate', web], + ['browser_read_text', web], + ['browser_get_html', web], + ['browser_get_links', web], + ['browser_summarize', web], + ['browser_snapshot', web], + ['browser_wait_for', web], + ['browser_screenshot', debug], + ['browser_click', input], + ['browser_hover', input], + ['browser_fill', input], + ['browser_fill_form', input], + ['browser_type', input], + ['browser_press_key', input], + ['browser_upload_file', input], + ['browser_console', debug], + ['browser_network', debug] + ] + + const registry: Record = {} + for (const [name, tags] of entries) { + registry[name] = toolEntry('extension', 'browser', tags) + } + // browser_evaluate is disabled by default + registry.browser_evaluate = toolEntry( + 'extension', + 'browser', + debug, + 'none', + false + ) + return registry +} + export function createDefaultToolConfig(): ToolConfig { return { items: {}, registry: { - web_search: createToolMetaOverride({ - source: 'extension', - group: 'search', - tags: ['search', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_open: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_list_pages: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_select_page: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_close_page: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_navigate: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_read_text: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_get_html: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_get_links: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_summarize: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_snapshot: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_wait_for: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_screenshot: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'debug'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_click: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'input'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_hover: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'input'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_fill: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'input'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_fill_form: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'input'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_type: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'input'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_press_key: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'input'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_upload_file: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'input'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_evaluate: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'debug'], - defaultAvailability: { - enabled: false, - main: false, - chatluna: false, - characterScope: 'none' - } - }), - browser_console: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'debug'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - browser_network: createToolMetaOverride({ - source: 'extension', - group: 'browser', - tags: ['browser', 'web', 'debug'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - group_mute: createToolMetaOverride({ - source: 'extension', - group: 'plugin-common', - tags: ['plugin-common', 'group', 'moderation'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'group' - } - }), - file_read: createToolMetaOverride({ - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - file_write: createToolMetaOverride({ - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - file_edit: createToolMetaOverride({ - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - file_publish: createToolMetaOverride({ - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - grep: createToolMetaOverride({ - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - glob: createToolMetaOverride({ - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - bash: createToolMetaOverride({ - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }), - task: createToolMetaOverride({ - source: 'extension', - group: 'agent', - tags: ['handoff'], - defaultAvailability: { - enabled: true, - main: true, - chatluna: true, - characterScope: 'all' - } - }) + web_search: toolEntry('extension', 'search', ['search', 'web']), + ...buildBrowserRegistry(), + group_mute: toolEntry( + 'extension', + 'plugin-common', + ['plugin-common', 'group', 'moderation'], + 'group' + ), + file_read: builtinEntry(), + file_write: builtinEntry(), + file_edit: builtinEntry(), + file_publish: builtinEntry(), + grep: builtinEntry(), + glob: builtinEntry(), + bash: builtinEntry(), + task: toolEntry('extension', 'agent', ['handoff']) } } } diff --git a/packages/extension-agent/src/config/read.ts b/packages/extension-agent/src/config/read.ts index 6f122618a..fe102d1a2 100644 --- a/packages/extension-agent/src/config/read.ts +++ b/packages/extension-agent/src/config/read.ts @@ -15,6 +15,60 @@ import { getDefaultConfig } from './defaults' +function mergeSkills(base: AgentConfig['skills'], cfg: AgentConfig['skills']) { + return { + ...base, + ...cfg, + items: Object.fromEntries( + Object.entries({ + ...(base.items ?? {}), + ...(cfg?.items ?? {}) + }).map(([id, item]) => [id, createSkillItemConfig(item)]) + ), + dirs: [...(cfg?.dirs ?? base.dirs)] + } +} + +function mergeToolRegistry( + base: AgentConfig['tool']['registry'], + saved: AgentConfig['tool']['registry'] +) { + const keys = Object.keys({ ...(base ?? {}), ...(saved ?? {}) }) + return Object.fromEntries( + keys.map((name) => { + const b = base?.[name] + const s = saved?.[name] + return [ + name, + createToolMetaOverride({ + ...(b ?? {}), + ...(s ?? {}), + defaultAvailability: { + ...(createToolDefaultAvailability(b) ?? {}), + ...(createToolDefaultAvailability(s) ?? {}) + } + }) + ] + }) + ) +} + +function mergeTool( + base: AgentConfig['tool'], + cfg?: Partial +) { + return { + ...base, + registry: mergeToolRegistry(base.registry, cfg?.registry), + items: Object.fromEntries( + Object.entries(cfg?.items ?? {}).map(([name, item]) => [ + name, + createToolItemConfig(item, name) + ]) + ) + } +} + export async function readConfig(ctx: Context): Promise { const path = getConfigPath(ctx) try { @@ -24,51 +78,8 @@ export async function readConfig(ctx: Context): Promise { return { ...base, ...cfg, - skills: { - ...base.skills, - ...(cfg.skills ?? {}), - items: Object.fromEntries( - Object.entries({ - ...(base.skills.items ?? {}), - ...(cfg.skills?.items ?? {}) - }).map(([id, item]) => [id, createSkillItemConfig(item)]) - ), - dirs: [...(cfg.skills?.dirs ?? base.skills.dirs)] - }, - tool: { - ...base.tool, - registry: Object.fromEntries( - Object.keys({ - ...(base.tool.registry ?? {}), - ...(cfg.tool?.registry ?? {}) - }).map((name) => { - const baseItem = base.tool.registry?.[name] - const saved = cfg.tool?.registry?.[name] - return [ - name, - createToolMetaOverride({ - ...(baseItem ?? {}), - ...(saved ?? {}), - defaultAvailability: { - ...(createToolDefaultAvailability( - baseItem - ) ?? {}), - ...(createToolDefaultAvailability(saved) ?? - {}) - } - }) - ] - }) - ), - items: Object.fromEntries( - Object.entries(cfg.tool?.items ?? {}).map( - ([name, item]) => [ - name, - createToolItemConfig(item, name) - ] - ) - ) - }, + skills: mergeSkills(base.skills, cfg.skills), + tool: mergeTool(base.tool, cfg.tool), trigger: deepAssign({}, base.trigger, cfg.trigger ?? {}), computer: deepAssign({}, base.computer, cfg.computer ?? {}), subAgent: deepAssign({}, base.subAgent, cfg.subAgent ?? {}) diff --git a/packages/extension-agent/src/service/computer.ts b/packages/extension-agent/src/service/computer.ts index 1b9f06da1..f2b30385e 100644 --- a/packages/extension-agent/src/service/computer.ts +++ b/packages/extension-agent/src/service/computer.ts @@ -225,19 +225,17 @@ export class ChatLunaAgentComputerService { ) } - const cwd = options.workdir const marker = `__CHATLUNA_BACKGROUND_EXIT__${randomUUID().replaceAll('-', '')}` - const userSession = options.runConfig?.configurable?.session const wrapped = session.prepareBackgroundCommand ? await session.prepareBackgroundCommand(command, marker, { - workdir: cwd, - session: userSession + workdir: options.workdir, + session: options.runConfig?.configurable?.session }) : `${command}\n` const terminal = await this.createManagedTerminal( session, { - cwd, + cwd: options.workdir, cols: 120, rows: 30 }, @@ -251,7 +249,7 @@ export class ChatLunaAgentComputerService { url: terminal.info.url, token: terminal.info.token, command, - cwd, + cwd: options.workdir, state: 'running', startedAt: Date.now(), timeout: options.timeout, @@ -306,8 +304,8 @@ export class ChatLunaAgentComputerService { terminalId: string, state?: Extract ) { - const session = this._terminals.get(sessionId) - const terminal = session?.get(terminalId) + const map = this._terminals.get(sessionId) + const terminal = map?.get(terminalId) if (!terminal) { return } @@ -321,8 +319,8 @@ export class ChatLunaAgentComputerService { } await terminal.terminal.kill() - session?.delete(terminalId) - if (session && session.size < 1) { + map?.delete(terminalId) + if (map && map.size < 1) { this._terminals.delete(sessionId) } } @@ -339,23 +337,25 @@ export class ChatLunaAgentComputerService { ) if (!backend) { if (!options.allowedBackends) { - const type = - options.backend ?? this.config.computer.defaultProvider - const status = this._status.backends[type] - throw new Error(formatBackendUnavailable(status)) + throw new Error( + formatBackendUnavailable( + this._status.backends[ + options.backend ?? + this.config.computer.defaultProvider + ] + ) + ) } throw new Error('No supported computer backend is available.') } - const key = buildComputerSessionKey({ - backend, - conversationId: options.conversationId, - userId: options.userId - }) - const session = await this._sessions.getOrCreate( - key, + buildComputerSessionKey({ + backend, + conversationId: options.conversationId, + userId: options.userId + }), { backend, conversationId: options.conversationId, @@ -401,8 +401,9 @@ export class ChatLunaAgentComputerService { getCapabilities(type?: ComputerBackendType) { if (type) { - const status = this._status.backends[type] - return !isAvailableBackend(status) ? [] : [...status.capabilities] + return !isAvailableBackend(this._status.backends[type]) + ? [] + : [...this._status.backends[type].capabilities] } return Array.from( @@ -441,26 +442,26 @@ export class ChatLunaAgentComputerService { } if (process.platform === 'win32') { - const bin = name.toLowerCase() + const lower = name.toLowerCase() if ( - bin === 'bash' || - bin === 'bash.exe' || - bin === 'sh' || - bin === 'sh.exe' + lower === 'bash' || + lower === 'bash.exe' || + lower === 'sh' || + lower === 'sh.exe' ) { return (await findGitBash()) != null } if ( - bin === 'pwsh' || - bin === 'pwsh.exe' || - bin === 'powershell' || - bin === 'powershell.exe' + lower === 'pwsh' || + lower === 'pwsh.exe' || + lower === 'powershell' || + lower === 'powershell.exe' ) { return findPowerShell() != null } - if (bin === 'cmd' || bin === 'cmd.exe') { + if (lower === 'cmd' || lower === 'cmd.exe') { return true } } @@ -478,9 +479,7 @@ export class ChatLunaAgentComputerService { const result = await session .execute( `sh -lc ${quoteShell(`command -v ${name} >/dev/null 2>&1`)}`, - { - timeout: 5000 - } + { timeout: 5000 } ) .catch(() => undefined) @@ -558,11 +557,11 @@ export class ChatLunaAgentComputerService { } } const token = randomUUID() - const list = + const map = this._terminals.get(session.sessionId) ?? new Map() - list.set(terminal.id, { terminal, persistent, token }) - this._terminals.set(session.sessionId, list) + map.set(terminal.id, { terminal, persistent, token }) + this._terminals.set(session.sessionId, map) return { info: { @@ -663,15 +662,16 @@ export class ChatLunaAgentComputerService { offset?: number, limit?: number ) { - const normalized = filePath.replaceAll('\\', '/') - const marker = '.chatluna/skills/' - const index = normalized.indexOf(marker) - if (index === -1) { + const idx = filePath.replaceAll('\\', '/').indexOf('.chatluna/skills/') + if (idx === -1) { throw new Error('Not a materialized skill path.') } - const rest = normalized.slice(index + marker.length) - const [name, ...parts] = rest.split('/').filter(Boolean) + const [name, ...parts] = filePath + .replaceAll('\\', '/') + .slice(idx + '.chatluna/skills/'.length) + .split('/') + .filter(Boolean) if (!name) { throw new Error('Invalid materialized skill path.') } @@ -687,9 +687,13 @@ export class ChatLunaAgentComputerService { session, this.ctx ) - const target = - parts.length > 0 ? `${root}/${parts.join('/')}` : `${root}/SKILL.md` - return await session.readFile(target, offset, limit) + return await session.readFile( + parts.length > 0 + ? `${root}/${parts.join('/')}` + : `${root}/SKILL.md`, + offset, + limit + ) } async getDesktopState( @@ -697,27 +701,12 @@ export class ChatLunaAgentComputerService { backend?: ComputerBackendType ): Promise { const session = await this.getOrCreateUiSession(clientId, backend) - const info = await session.getDesktopInfo?.() - const screenshot = session.screenshot - ? await session.screenshot().catch(() => undefined) - : undefined return { sessionId: session.sessionId, backend: session.backend, - info: info - ? { - width: info.width, - height: info.height, - streamUrl: info.streamUrl - } - : undefined, - screenshot: screenshot - ? { - data: screenshot.data, - mimeType: screenshot.mimeType, - width: screenshot.width, - height: screenshot.height - } + info: await session.getDesktopInfo?.(), + screenshot: session.screenshot + ? await session.screenshot().catch(() => undefined) : undefined } } @@ -763,23 +752,22 @@ export class ChatLunaAgentComputerService { filePaths: string[], runConfig?: ChatLunaToolRunnable ): Promise<{ url: string; name: string }[]> { - const storage = this.ctx.chatluna_storage - if (!storage) { + if (!this.ctx.chatluna_storage) { throw new Error('chatluna-storage-service is not available.') } - const computer = await this.getToolSession(runConfig) + const session = await this.getToolSession(runConfig) return await Promise.all( filePaths.map(async (filePath) => { - if (!computer.isInScope(filePath)) { + if (!session.isInScope(filePath)) { throw new Error(`Path is outside scope: ${filePath}`) } const fileName = path.posix.basename( filePath.replaceAll('\\', '/') ) - const asset = await computer.openAsset(filePath) - return await storage.createTempFileFromStream( + const asset = await session.openAsset(filePath) + return await this.ctx.chatluna_storage!.createTempFileFromStream( asset.stream, fileName, { @@ -817,35 +805,33 @@ export class ChatLunaAgentComputerService { }) } - private async removeRemoteEntry(path: string) { + private async removeRemoteEntry(entryPath: string) { const session = await this.getRemoteScanSession() if (!session) { throw new Error('Remote computer backend is not available.') } - const target = path.replaceAll('\\', '/') + const target = entryPath.replaceAll('\\', '/') if ( target.length < 2 || target === '/' || target === '~' || /^~?\/?\.?$/.test(target) ) { - throw new Error(`Refusing to remove unsafe path: ${path}`) + throw new Error(`Refusing to remove unsafe path: ${entryPath}`) } const quoted = quoteShellPath(target) const result = await session.execute( `if [ -d ${quoted} ]; then rm -rf ${quoted}; elif [ -e ${quoted} ]; then rm -f ${quoted}; fi`, - { - timeout: 15000 - } + { timeout: 15000 } ) if (result.exitCode !== 0) { throw new Error( result.stderr.trim() || result.stdout.trim() || - `Failed to remove ${path}` + `Failed to remove ${entryPath}` ) } } @@ -854,9 +840,7 @@ export class ChatLunaAgentComputerService { runConfig?: ChatLunaToolRunnable, backend?: ComputerBackendType ) { - const session = runConfig?.configurable?.session - const context = runConfig?.configurable?.agentContext - const sub = context?.subagentContext + const sub = runConfig?.configurable?.agentContext?.subagentContext const info = sub ? this.ctx.chatluna_agent?.subAgent .getCatalogSync() @@ -872,9 +856,11 @@ export class ChatLunaAgentComputerService { : undefined, conversationId: sub?.parentConversationId ?? - context?.conversationId ?? + runConfig?.configurable?.agentContext?.conversationId ?? runConfig?.configurable?.conversationId, - userId: runConfig?.configurable?.userId ?? session?.userId + userId: + runConfig?.configurable?.userId ?? + runConfig?.configurable?.session?.userId } } @@ -945,27 +931,14 @@ export class ChatLunaAgentComputerService { const timeout = this.config.computer.idleTimeoutMs this._idleDispose = this.ctx.setInterval( async () => { - const items = this._sessions.list().filter((item) => { + for (const item of this._sessions.list()) { if (this._sessions.isBusy(item.id)) { - return false - } - - if (this.hasRunningJobs(item.id)) { - return false - } - - return Date.now() - item.lastActiveAt >= timeout - }) - - for (const item of items) { - const info = this.getSessionInfo(item.id) - if (!info || this._sessions.isBusy(item.id)) { continue } if (this.hasRunningJobs(item.id)) { continue } - if (Date.now() - info.lastActiveAt < timeout) { + if (Date.now() - item.lastActiveAt < timeout) { continue } @@ -1017,10 +990,10 @@ export class ChatLunaAgentComputerService { preferred?: ComputerBackendType, allowedBackends?: ComputerBackendType[] ) { - const backends = allowedBackends ?? COMPUTER_BACKENDS + const list = allowedBackends ?? COMPUTER_BACKENDS const target = preferred ?? this.config.computer.defaultProvider if ( - backends.includes(target) && + list.includes(target) && isAvailableBackend(this._status.backends[target]) ) { return target @@ -1030,7 +1003,7 @@ export class ChatLunaAgentComputerService { return undefined } - for (const item of backends) { + for (const item of list) { if (item === target) { continue } @@ -1053,10 +1026,9 @@ export class ChatLunaAgentComputerService { } for (const item of COMPUTER_TOOLS) { - const tool = item.factory(this) this._toolDispose.push( this.ctx.chatluna.platform.registerTool(item.name, { - description: tool.description, + description: item.factory(this).description, selector: () => this._status.enabled, createTool: () => item.factory(this), meta: { @@ -1091,11 +1063,11 @@ export class ChatLunaAgentComputerService { const mask = (runtime.configurable as { toolMask?: ToolMask }) ?.toolMask const registry = this.ctx.chatluna.platform.getToolRegistry() - const names = + const capabilities = ( mask != null ? this.ctx.chatluna.platform.getFilteredTools(mask) : Object.keys(registry) - const capabilities = names.filter((name) => + ).filter((name) => registry[name]?.meta?.tags?.includes('computer') ) if (capabilities.length < 1) { @@ -1127,11 +1099,7 @@ export class ChatLunaAgentComputerService { private refreshStatus() { const status = this.buildStatus() const sessions = this._sessions.list() - const counts: Record = { - local: 0, - e2b: 0, - 'open-terminal': 0 - } + const counts = { local: 0, e2b: 0, 'open-terminal': 0 } for (const item of sessions) { counts[item.backend] += 1 @@ -1149,14 +1117,13 @@ export class ChatLunaAgentComputerService { const local = buildBackendStatus( 'local', this.config.computer.local.enabled, - [...BASE_CAPABILITIES], - undefined + BASE_CAPABILITIES ) const openTerminal = buildBackendStatus( 'open-terminal', this.config.computer.openTerminal.enabled, - [...BASE_CAPABILITIES], + BASE_CAPABILITIES, this.config.computer.openTerminal.enabled && !this.config.computer.openTerminal.baseUrl ? 'open-terminal baseUrl is empty.' @@ -1168,7 +1135,7 @@ export class ChatLunaAgentComputerService { this.config.computer.e2b.enabled, this.config.computer.e2b.desktopTemplate ? [...BASE_CAPABILITIES, ...E2B_EXTRA] - : [...BASE_CAPABILITIES], + : BASE_CAPABILITIES, this.config.computer.e2b.enabled && !this.resolveSecret(this.config.computer.e2b.apiKey) ? 'E2B apiKey is empty.' @@ -1191,10 +1158,9 @@ export class ChatLunaAgentComputerService { } private async closeAllTerminals(sessionId?: string) { - const entries: [string, Map | undefined][] = - sessionId - ? [[sessionId, this._terminals.get(sessionId)]] - : Array.from(this._terminals.entries()) + const entries = sessionId + ? [[sessionId, this._terminals.get(sessionId)] as const] + : Array.from(this._terminals.entries()) for (const [id, items] of entries) { if (!items) { continue diff --git a/packages/extension-agent/src/service/index.ts b/packages/extension-agent/src/service/index.ts index 22c98a5ef..4afe57e34 100644 --- a/packages/extension-agent/src/service/index.ts +++ b/packages/extension-agent/src/service/index.ts @@ -60,23 +60,22 @@ export class ChatLunaAgentService extends Service { public args: { config: AgentConfig; plugin: ChatLunaPlugin } ) { super(ctx, 'chatluna_agent') - const { config, plugin } = args - this.permission = new ChatLunaAgentPermissionService(ctx, config) - this.computer = new ChatLunaAgentComputerService(ctx, config) - this.mcp = new ChatLunaAgentMcpService(ctx, config, plugin) + this.permission = new ChatLunaAgentPermissionService(ctx, args.config) + this.computer = new ChatLunaAgentComputerService(ctx, args.config) + this.mcp = new ChatLunaAgentMcpService(ctx, args.config, args.plugin) this.runtimeSync = new ChatLunaAgentRuntimeSyncService(ctx, () => this) this.skills = new ChatLunaAgentSkillsService( ctx, - config, + args.config, this.permission ) this.subAgent = new ChatLunaAgentSubAgentService( ctx, - config, + args.config, this.permission ) - this.trigger = new ChatLunaAgentTriggerService(ctx, config.trigger) + this.trigger = new ChatLunaAgentTriggerService(ctx, args.config.trigger) } async start() { @@ -162,10 +161,9 @@ export class ChatLunaAgentService extends Service { } async saveConfig(cfg: AgentConfig) { - const next = cfg - await writeConfig(this.ctx, next) - this._setConfig(next) - await this.reload(next) + await writeConfig(this.ctx, cfg) + this._setConfig(cfg) + await this.reload(cfg) } async saveMcpConfig(mcp: AgentConfig['mcp']) { @@ -210,16 +208,17 @@ export class ChatLunaAgentService extends Service { async importSkills(input: SkillImportInput): Promise { const result = await this.skills.importSkills(input) - const skills = { - dirs: [...this.args.config.skills.dirs], - items: { ...this.args.config.skills.items }, - githubToken: this.args.config.skills.githubToken ?? '' - } - - await this.updateConfig('skills', skills, async () => { - await this.skills.reload() - }) - + await this.updateConfig( + 'skills', + { + dirs: [...this.args.config.skills.dirs], + items: { ...this.args.config.skills.items }, + githubToken: this.args.config.skills.githubToken ?? '' + }, + async () => { + await this.skills.reload() + } + ) return result } @@ -284,7 +283,7 @@ export class ChatLunaAgentService extends Service { ...skills.items[id], enabled: mode !== 'off', mode, - remote: info?.remote === true || skills.items[id]?.remote === true + remote: info?.remote || skills.items[id]?.remote === true } await this.updateConfig('skills', skills, async () => { await this.skills.reload() @@ -306,7 +305,6 @@ export class ChatLunaAgentService extends Service { } async setSubAgentEnabled(id: string, enabled: boolean) { - const subAgent = structuredClone(this.args.config.subAgent) const info = this.subAgent .getCatalogSync() .find((item) => item.id === id) @@ -314,13 +312,16 @@ export class ChatLunaAgentService extends Service { throw new Error(`Sub-agent not found: ${id}`) } + if (info.source === 'manual') { + await this.subAgent.setManualAgentEnabled(id, enabled) + return + } + + const subAgent = structuredClone(this.args.config.subAgent) if (info.source === 'builtin') { subAgent.builtin[info.name] = itemFromInfo(info, enabled) } else if (info.source === 'preset') { subAgent.presetAgents[info.name] = itemFromInfo(info, enabled) - } else if (info.source === 'manual') { - await this.subAgent.setManualAgentEnabled(id, enabled) - return } else { subAgent.items[id] = itemFromInfo(info, enabled) } @@ -351,10 +352,10 @@ export class ChatLunaAgentService extends Service { await this.subAgent.reload() await this.refreshConsoleData() - const path = resolve(file) + const resolved = resolve(file) const info = this.subAgent .getCatalogSync() - .find((item) => item.path && resolve(item.path) === path) + .find((item) => item.path && resolve(item.path) === resolved) if (!info) { throw new Error( @@ -420,15 +421,15 @@ export class ChatLunaAgentService extends Service { await this.subAgent.reload() await this.refreshConsoleData() - const path = resolve(info.path) - const next = this.subAgent + const resolved = resolve(info.path) + const updated = this.subAgent .getCatalogSync() - .find((item) => item.path && resolve(item.path) === path) - if (!next) { + .find((item) => item.path && resolve(item.path) === resolved) + if (!updated) { throw new Error(`Sub-agent was saved but not found: ${id}`) } - return next + return updated } async exportSubAgent( @@ -608,9 +609,7 @@ export class ChatLunaAgentService extends Service { outputDir?: string }) { const limit = input.limit ?? 8000 - if (input.text.length <= limit) { - return input.text - } + if (input.text.length <= limit) return input.text if (input.session) { const base = @@ -622,8 +621,9 @@ export class ChatLunaAgentService extends Service { : base === '/' ? '/' : base.replace(/[\\/]+$/, '') - const sep = root.endsWith('/') ? '' : '/' - const filePath = `${root}${sep}.tmp-chatluna-${input.name}-${Date.now()}-${randomUUID()}.txt` + const filePath = + `${root}${root.endsWith('/') ? '' : '/'}` + + `.tmp-chatluna-${input.name}-${Date.now()}-${randomUUID()}.txt` try { await input.session.writeFile(filePath, input.text) diff --git a/packages/extension-agent/src/service/mcp.ts b/packages/extension-agent/src/service/mcp.ts index 109ea0a87..fd6959876 100644 --- a/packages/extension-agent/src/service/mcp.ts +++ b/packages/extension-agent/src/service/mcp.ts @@ -36,15 +36,17 @@ export class ChatLunaAgentMcpService { this._stopped = false this.ctx.logger.info('Starting MCP service') - const cfgs = this.config.mcp.mcpServers - if (!cfgs || Object.keys(cfgs).length === 0) { + if ( + !this.config.mcp.mcpServers || + Object.keys(this.config.mcp.mcpServers).length === 0 + ) { this.ctx.logger.warn('No MCP servers available') this.ctx.chatluna_agent?.refreshConsoleData() return } await Promise.all( - Object.entries(cfgs).map(([name, cfg]) => { + Object.entries(this.config.mcp.mcpServers).map(([name, cfg]) => { this._servers.set(name, { state: 'idle', attempts: 0 }) return this._connect(name, cfg) }) @@ -76,9 +78,8 @@ export class ChatLunaAgentMcpService { } async reload() { - const active = Array.from(this._servers.keys()) await Promise.all( - active + Array.from(this._servers.keys()) .filter((name) => !this.config.mcp.mcpServers[name]) .map((name) => this._remove(name)) ) @@ -172,8 +173,6 @@ export class ChatLunaAgentMcpService { const srv = this._servers.get(name) const state = srv?.state ?? 'idle' const type = cfg.type ?? (cfg.url ? 'http' : 'stdio') - const endpoint = - type === 'stdio' ? (cfg.command ?? '') : (cfg.url ?? '') servers[name] = { name, @@ -193,7 +192,8 @@ export class ChatLunaAgentMcpService { maxAttempts: 5, pendingReconnect: !!srv?.reconnectDispose, type, - endpoint, + endpoint: + type === 'stdio' ? (cfg.command ?? '') : (cfg.url ?? ''), title: srv?.title, version: srv?.version, icon: srv?.icon @@ -251,7 +251,6 @@ export class ChatLunaAgentMcpService { try { await this._drop(name, false) - const transport = createTransport(name, cfg, this.plugin) const client = new Client({ name: 'ChatLuna', version: '1.0.0', @@ -259,7 +258,7 @@ export class ChatLunaAgentMcpService { }) this._setupHandlers(client, name, cfg) - await client.connect(transport) + await client.connect(createTransport(name, cfg, this.plugin)) const meta = client.getServerVersion() const srv = this._servers.get(name) @@ -278,9 +277,11 @@ export class ChatLunaAgentMcpService { this.ctx.logger.debug(`MCP client connected: ${name}`) return true } catch (error) { - const text = + await this._fail( + name, + cfg, error instanceof Error ? error.message : String(error) - await this._fail(name, cfg, text) + ) this.ctx.logger.error( `Failed to connect to server ${name}`, error @@ -338,9 +339,11 @@ export class ChatLunaAgentMcpService { this.ctx.chatluna_agent?.refreshConsoleData() logger.info(`Tools updated for server: ${name}`) } catch (error) { - const text = + await this._fail( + name, + cfg, error instanceof Error ? error.message : String(error) - await this._fail(name, cfg, text) + ) logger.error( `Failed to handle tool list change for ${name}`, error @@ -368,11 +371,12 @@ export class ChatLunaAgentMcpService { return } - const delay = Math.min(1000 * Math.pow(2, attempts), 30000) if (srv) { srv.state = 'reconnecting' } this.ctx.chatluna_agent?.refreshConsoleData() + + const delay = Math.min(1000 * Math.pow(2, attempts), 30000) logger.info( `Scheduling reconnect in ${delay}ms (attempt ${attempts + 1}/5)` ) diff --git a/packages/extension-agent/src/service/permissions.ts b/packages/extension-agent/src/service/permissions.ts index 66db8c5e6..37470790f 100644 --- a/packages/extension-agent/src/service/permissions.ts +++ b/packages/extension-agent/src/service/permissions.ts @@ -59,19 +59,8 @@ export class ChatLunaAgentPermissionService { } mergeRule(rule: PermissionRule, fallback: PermissionRule): PermissionRule { - if (rule.mode !== 'inherit') { - return { - mode: rule.mode, - allow: [...rule.allow], - deny: [...rule.deny] - } - } - - return { - mode: fallback.mode, - allow: [...fallback.allow], - deny: [...fallback.deny] - } + const src = rule.mode !== 'inherit' ? rule : fallback + return { mode: src.mode, allow: [...src.allow], deny: [...src.deny] } } mergePermissions( @@ -120,19 +109,18 @@ export class ChatLunaAgentPermissionService { } filterComputerBackends(info: SubAgentInfo, names: ComputerBackendType[]) { - const raw = info.permissions.computer const rule = - raw.mode === 'inherit' + info.permissions.computer.mode === 'inherit' ? this.config.subAgent.defaults.computer - : raw - const allow = rule.allow.filter(isComputerBackend) - const deny = rule.deny.filter(isComputerBackend) + : info.permissions.computer if (rule.mode === 'allow') { + const allow = rule.allow.filter(isComputerBackend) return names.filter((name) => allow.includes(name)) } if (rule.mode === 'deny') { + const deny = rule.deny.filter(isComputerBackend) return names.filter((name) => !deny.includes(name)) } @@ -157,43 +145,30 @@ export class ChatLunaAgentPermissionService { const list = Object.values(registry) .map((item) => { const saved = this.config.tool.items[item.name] - const defaultAvailability = createToolDefaultAvailability( - item.meta - ) + const avail = createToolDefaultAvailability(item.meta) const cfg = createToolItemConfig( { ...saved, - enabled: - saved?.enabled ?? - defaultAvailability?.enabled ?? - true, - main: saved?.main ?? defaultAvailability?.main ?? true, - chatluna: - saved?.chatluna ?? - defaultAvailability?.chatluna ?? - true, + enabled: saved?.enabled ?? avail?.enabled ?? true, + main: saved?.main ?? avail?.main ?? true, + chatluna: saved?.chatluna ?? avail?.chatluna ?? true, character: saved?.character ?? - (defaultAvailability?.characterScope == null + (avail?.characterScope == null ? true - : defaultAvailability.characterScope !== - 'none'), + : avail.characterScope !== 'none'), characterGroup: saved?.characterGroup ?? - (defaultAvailability?.characterScope == null + (avail?.characterScope == null ? true - : defaultAvailability.characterScope === - 'all' || - defaultAvailability.characterScope === - 'group'), + : avail.characterScope === 'all' || + avail.characterScope === 'group'), characterPrivate: saved?.characterPrivate ?? - (defaultAvailability?.characterScope == null + (avail?.characterScope == null ? true - : defaultAvailability.characterScope === - 'all' || - defaultAvailability.characterScope === - 'private'), + : avail.characterScope === 'all' || + avail.characterScope === 'private'), characterGroupMode: saved?.characterGroupMode ?? 'all', characterPrivateMode: saved?.characterPrivateMode ?? 'all', @@ -282,17 +257,12 @@ export class ChatLunaAgentPermissionService { const tools = this.listTools() const allNames = tools.map((item) => item.name) const allow = tools - .filter((item) => { - if (!item.enabled) { - return false - } - - if (!item.main) { - return false - } - - return this.isSessionAllowed(session, source, item) - }) + .filter( + (item) => + item.enabled && + item.main && + this.isSessionAllowed(session, source, item) + ) .map((item) => item.name) return buildToolMask(allNames, allow) @@ -330,10 +300,11 @@ export class ChatLunaAgentPermissionService { } hasAuthority(session?: Session, authority = 0) { - const auth = - (session as Session | undefined)?.user?.['authority'] ?? - 0 - return auth >= authority + return ( + ((session as Session | undefined)?.user?.[ + 'authority' + ] ?? 0) >= authority + ) } isSessionAllowed( @@ -442,9 +413,13 @@ export class ChatLunaAgentPermissionService { } if (tool.tags?.includes('computer')) { - const backends = - this.ctx.chatluna_agent?.computer.listAvailableBackends() ?? [] - if (this.filterComputerBackends(info, backends).length < 1) { + if ( + this.filterComputerBackends( + info, + this.ctx.chatluna_agent?.computer.listAvailableBackends() ?? + [] + ).length < 1 + ) { return false } } @@ -460,35 +435,36 @@ export class ChatLunaAgentPermissionService { } getRegistry() { - const registry = this.ctx.chatluna.platform.getToolRegistry() return Object.fromEntries( - Object.values(registry).map((item) => { - const saved = this.config.tool.registry?.[item.name] - const defaultAvailability = { - ...(createToolDefaultAvailability(item.meta) ?? {}), - ...(createToolDefaultAvailability(saved) ?? {}) - } - return [ - item.name, - { - name: item.name, - description: item.description, - meta: { - ...item.meta, - source: saved?.source ?? item.meta?.source, - group: saved?.group ?? item.meta?.group, - tags: - saved?.tags && saved.tags.length > 0 - ? saved.tags - : item.meta?.tags, - defaultAvailability: - Object.keys(defaultAvailability).length > 0 - ? defaultAvailability - : undefined - } + Object.values(this.ctx.chatluna.platform.getToolRegistry()).map( + (item) => { + const saved = this.config.tool.registry?.[item.name] + const avail = { + ...(createToolDefaultAvailability(item.meta) ?? {}), + ...(createToolDefaultAvailability(saved) ?? {}) } - ] - }) + return [ + item.name, + { + name: item.name, + description: item.description, + meta: { + ...item.meta, + source: saved?.source ?? item.meta?.source, + group: saved?.group ?? item.meta?.group, + tags: + saved?.tags && saved.tags.length > 0 + ? saved.tags + : item.meta?.tags, + defaultAvailability: + Object.keys(avail).length > 0 + ? avail + : undefined + } + } + ] + } + ) ) } diff --git a/packages/extension-agent/src/service/skills.ts b/packages/extension-agent/src/service/skills.ts index 7cc8453b0..c9efb87ca 100644 --- a/packages/extension-agent/src/service/skills.ts +++ b/packages/extension-agent/src/service/skills.ts @@ -122,8 +122,7 @@ export class ChatLunaAgentSkillsService implements SkillToolService { async reload() { await syncBundledSkills(this.ctx) - const local = await scanSkills(this.ctx, this.config) - const scanned = local + const scanned = await scanSkills(this.ctx, this.config) this._skills = new Map(scanned.map((s) => [s.id, s])) this._catalog = buildSkillCatalog(scanned, this.config.skills.items) this.ctx.chatluna_agent?.computer.materializer.clear() @@ -140,17 +139,17 @@ export class ChatLunaAgentSkillsService implements SkillToolService { } getStatus(): SkillsStatus { - const catalog = this.getDisplayCatalog() + const items = this.getDisplayCatalog() return { enabled: true, root: getSkillsRootPath(this.ctx), - total: catalog.length, - visible: catalog.filter((s) => s.visible).length, - modelEnabled: catalog.filter((s) => s.modelEnabled).length, + total: items.length, + visible: items.filter((s) => s.visible).length, + modelEnabled: items.filter((s) => s.modelEnabled).length, activeConversations: Array.from(this._active.values()).filter( (s) => s.size > 0 ).length, - catalog: Object.fromEntries(catalog.map((s) => [s.id, s])) + catalog: Object.fromEntries(items.map((s) => [s.id, s])) } } @@ -174,19 +173,13 @@ export class ChatLunaAgentSkillsService implements SkillToolService { hasActiveSkill(conversationId: string, name: string) { const skill = this.getVisibleSkillByName(name) - if (!skill) { - return false - } - + if (!skill) return false return this._active.get(conversationId)?.has(skill.id) === true } listActiveSkills(conversationId: string) { const active = this._active.get(conversationId) - if (!active) { - return [] as SkillInfo[] - } - + if (!active) return [] as SkillInfo[] return this._catalog.filter((item) => active.has(item.id)) } @@ -214,17 +207,17 @@ export class ChatLunaAgentSkillsService implements SkillToolService { async previewImport( input: SkillImportInput ): Promise { - return await previewSkillsImport(this.ctx, input) + return previewSkillsImport(this.ctx, input) } async importSkills(input: SkillImportInput): Promise { - return await runImportSkills(this.ctx, input) + return runImportSkills(this.ctx, input) } async exportSkill(id: string): Promise { const skill = this._skills.get(id) if (!skill?.path || skill.remote) return undefined - return await exportSkillArchive(id, skill.dir) + return exportSkillArchive(id, skill.dir) } async removeSkill(id: string): Promise { @@ -312,17 +305,13 @@ export class ChatLunaAgentSkillsService implements SkillToolService { this._active.set(conversationId, active) } - return await this.renderActivatedSkill(skill, { - conversationId, - runConfig - }) + return this.renderActivatedSkill(skill, { conversationId, runConfig }) } async renderSkill(name: string, loaded = false) { const skill = this.getVisibleSkillByName(name) if (!skill?.enabled || skill.state !== 'ready') return undefined - - return await renderSkillContent(skill, loaded) + return renderSkillContent(skill, loaded) } private hasComputer() { @@ -337,7 +326,7 @@ export class ChatLunaAgentSkillsService implements SkillToolService { session?: Session, source: 'chatluna' | 'character' = 'chatluna' ) { - const item = this._catalog.find((skill) => skill.id === id) + const item = this._catalog.find((s) => s.id === id) if (!item || !item.enabled || item.state !== 'ready' || !item.main) { return false } @@ -387,7 +376,7 @@ export class ChatLunaAgentSkillsService implements SkillToolService { ? await listRemoteSkillResources(session, skillDir ?? skill.dir) : undefined - return await renderSkillContent(skill, input.loaded ?? true, { + return renderSkillContent(skill, input.loaded ?? true, { skillDir, resources }) @@ -398,21 +387,17 @@ export class ChatLunaAgentSkillsService implements SkillToolService { remote: boolean ) { const current = this._active.get(conversationId) - if (!current) { - return [] as SkillInfo[] - } + if (!current) return [] as SkillInfo[] const items = this._catalog.filter((item) => current.has(item.id)) - if (!remote) { - return items - } + if (!remote) return items const computer = this.ctx.chatluna_agent?.computer const session = await computer ?.getOrCreateSession({ conversationId }) .catch(() => undefined) - return await Promise.all( + return Promise.all( items.map(async (item) => { if (item.remote || !session || !computer) { return { @@ -450,15 +435,15 @@ export class ChatLunaAgentSkillsService implements SkillToolService { .map((s) => s.id) ) - for (const [conversationId, current] of this._active.entries()) { + for (const [id, current] of this._active.entries()) { const next = new Set( - Array.from(current).filter((id) => loadable.has(id)) + Array.from(current).filter((sid) => loadable.has(sid)) ) if (next.size > 0) { - this._active.set(conversationId, next) + this._active.set(id, next) } else { - this._active.delete(conversationId) + this._active.delete(id) } } } diff --git a/packages/extension-agent/src/service/sub_agent.ts b/packages/extension-agent/src/service/sub_agent.ts index c29397ef8..691db2faa 100644 --- a/packages/extension-agent/src/service/sub_agent.ts +++ b/packages/extension-agent/src/service/sub_agent.ts @@ -61,11 +61,11 @@ export class ChatLunaAgentSubAgentService { } getStatus(): SubAgentStatus { - const catalog = this.getCatalogSync() + const items = this.getCatalogSync() return { - enabled: catalog.length > 0, - total: catalog.length, - catalog: Object.fromEntries(catalog.map((item) => [item.id, item])), + enabled: items.length > 0, + total: items.length, + catalog: Object.fromEntries(items.map((item) => [item.id, item])), runs: this.getRuns() } } @@ -130,10 +130,7 @@ export class ChatLunaAgentSubAgentService { } satisfies ManualSubAgentInput const info = createManualAgent(this.ctx, next) - this._manual.set(info.id, { - ...next, - id: info.id - }) + this._manual.set(info.id, { ...next, id: info.id }) await this.refreshCatalog() await this.ctx.chatluna_agent?.refreshConsoleData() @@ -226,8 +223,7 @@ export class ChatLunaAgentSubAgentService { this._toolDispose?.() this._toolDispose = undefined - const names = this.listRunnableAgents().map((item) => item.name) - if (names.length < 1) return + if (this.listRunnableAgents().length < 1) return this._toolDispose = this.ctx.chatluna.platform.registerTool('task', { description: this.buildToolDescription(), @@ -256,9 +252,7 @@ export class ChatLunaAgentSubAgentService { this._promptDispose = this.ctx.chatluna.contextManager.pipeline( 'after_system_prompts', async (runtime: PromptContextRuntime, next) => { - const conversationId = runtime.configurable?.conversationId - if (!conversationId) return next() - + if (!runtime.configurable?.conversationId) return next() if (runtime.configurable?.subagentContext) return next() const session = runtime.configurable?.session diff --git a/packages/extension-agent/src/service/trigger.ts b/packages/extension-agent/src/service/trigger.ts index 849517b62..eab57e5a6 100644 --- a/packages/extension-agent/src/service/trigger.ts +++ b/packages/extension-agent/src/service/trigger.ts @@ -270,9 +270,10 @@ export class ChatLunaAgentTriggerService { sourceOrInput: Session | WakeupRouting | TriggerCreateTaskInput, opts?: CreateTaskFromSessionOptions ) { - const input = this._deriveCreateInput(sourceOrInput, opts) - const prepared = await this._prepareTaskInput(input) - const task = await this._registry.create(prepared) + const input = await this._prepareTaskInput( + this._deriveCreateInput(sourceOrInput, opts) + ) + const task = await this._registry.create(input) await this._providers.get(task.providerKind)?.onTaskCreate?.({ task }) this._scheduler.sync(task) await this._afterMutate() @@ -303,7 +304,7 @@ export class ChatLunaAgentTriggerService { async fire(id: number, session?: Session) { const result = await this._fireTask( id, - session == null ? undefined : { target: session }, + session ? { target: session } : undefined, false ) await this._afterMutate() @@ -403,18 +404,11 @@ export class ChatLunaAgentTriggerService { [kind]: { enabled } } - if (!enabled) { - // Remove scheduled timers for all tasks of this provider so disabled - // providers don't continue to fire from the existing schedule. - for (const task of await this._registry.list({ - providerKind: kind - })) { + const tasks = await this._registry.list({ providerKind: kind }) + for (const task of tasks) { + if (!enabled) { this._scheduler.remove(task.id) - } - } else { - for (const task of await this._registry.list({ - providerKind: kind - })) { + } else { this._scheduler.sync(task) } } @@ -462,18 +456,18 @@ export class ChatLunaAgentTriggerService { const routing = isSession ? routingFromSession(sourceOrInput as Session) : (sourceOrInput as WakeupRouting) - const bindingKey = - o.bindingKey ?? - bindingKeyFromRouting(routing, o.scope ?? 'personal') - const createdBy = - o.createdBy ?? - (isSession ? (sourceOrInput as Session).userId : routing.userId) return { ...o, ...routing, - bindingKey, - createdBy, + bindingKey: + o.bindingKey ?? + bindingKeyFromRouting(routing, o.scope ?? 'personal'), + createdBy: + o.createdBy ?? + (isSession + ? (sourceOrInput as Session).userId + : routing.userId), wakeupTemplate: o.wakeupTemplate ?? {} } as TriggerCreateTaskInput } @@ -485,7 +479,11 @@ export class ChatLunaAgentTriggerService { const target = copy.target if (target == null) return copy - if (this._isSession(target)) { + if ( + typeof target === 'object' && + 'bot' in target && + 'platform' in target + ) { return { ...copy, target: routingFromSession(target as Session) @@ -494,15 +492,6 @@ export class ChatLunaAgentTriggerService { return copy } - private _isSession(target: WakeupTarget): target is Session { - return ( - typeof target === 'object' && - target != null && - 'bot' in target && - 'platform' in target - ) - } - private async _afterMutate() { await this._refreshStatus() await this.ctx.chatluna_agent?.refreshConsoleData() @@ -523,9 +512,7 @@ export class ChatLunaAgentTriggerService { task.providerKind === 'cron' && task.nextFireAt != null && task.nextFireAt.valueOf() < Date.now() - const missedRunPolicy = - task.params?.missedRunPolicy === 'fire_once' ? 'fire_once' : 'skip' - if (overdue && missedRunPolicy === 'skip') { + if (overdue && task.params?.missedRunPolicy !== 'fire_once') { const requestId = randomUUID() const provider = this._providers.get(task.providerKind) const result: WakeupResult = { @@ -561,11 +548,10 @@ export class ChatLunaAgentTriggerService { : undefined const target = override?.target ?? - (taskRouting != null - ? taskRouting - : task.bindingKey != null - ? { bindingKey: task.bindingKey } - : undefined) + taskRouting ?? + (task.bindingKey != null + ? { bindingKey: task.bindingKey } + : undefined) const requestId = override?.requestId ?? randomUUID() const merged: WakeupAction = { ...task.wakeupTemplate, @@ -610,13 +596,6 @@ export class ChatLunaAgentTriggerService { const provider = this._providers.get(task.providerKind) const firedAt = new Date() - const persistConversationId = - task.wakeupTemplate.newConversation === true && - task.conversationId == null && - result.ok && - result.conversation != null - ? result.conversation.id - : undefined const latest = await this._registry.get(id) if (latest == null) return result @@ -693,8 +672,11 @@ export class ChatLunaAgentTriggerService { const updated = await this._registry.update(id, { lastFiredAt: firedAt, fireCount: task.fireCount + 1, - ...(persistConversationId != null - ? { conversationId: persistConversationId } + ...(task.wakeupTemplate.newConversation === true && + task.conversationId == null && + result.ok && + result.conversation != null + ? { conversationId: result.conversation.id } : {}), ...schedule, lastError: result.ok ? null : err @@ -712,19 +694,19 @@ export class ChatLunaAgentTriggerService { pendingKey: string, item: DeferredWakeup ) { - const items = - this._deferred.get(pendingKey) ?? new Map() - items.set(key, item) - this._deferred.set(pendingKey, items) + if (!this._deferred.has(pendingKey)) { + this._deferred.set(pendingKey, new Map()) + } + this._deferred.get(pendingKey).set(key, item) } private async _replayDeferred(platform: string, selfId: string) { - const pendingKey = `${platform}:${selfId}` - const items = this._deferred.get(pendingKey) + const key = `${platform}:${selfId}` + const items = this._deferred.get(key) if (items == null || items.size < 1) return let changed = false - this._deferred.delete(pendingKey) + this._deferred.delete(key) for (const item of items.values()) { try { if (item.taskId == null) { @@ -790,9 +772,7 @@ export class ChatLunaAgentTriggerService { this._promptDispose = this.ctx.chatluna.contextManager.pipeline( 'after_system_prompts', async (runtime: PromptContextRuntime, next) => { - const conversationId = runtime.configurable?.conversationId - if (!conversationId) return next() - + if (!runtime.configurable?.conversationId) return next() if (runtime.configurable?.subagentContext) return next() const mask = (runtime.configurable as { toolMask?: ToolMask }) @@ -806,23 +786,25 @@ export class ChatLunaAgentTriggerService { return next() } - const current = this.getEnabledProviders() - if (current.length < 1) return next() + const providers = this.getEnabledProviders() + if (providers.length < 1) return next() - const msg = renderTriggerProviders(current) + const msg = renderTriggerProviders(providers) runtime.result.push(msg) runtime.usedTokens += await countMessageTokens( msg, runtime.tokenCounter ) - const requestId = ( - runtime.configurable as { - agentContext?: { requestId?: string } - } - ).agentContext?.requestId - const id = this.getRunningTaskId(requestId) - const task = id == null ? undefined : await this.getTask(id) + const taskId = this.getRunningTaskId( + ( + runtime.configurable as { + agentContext?: { requestId?: string } + } + ).agentContext?.requestId + ) + const task = + taskId == null ? undefined : await this.getTask(taskId) if (task != null) { const self = renderTriggerSelfControl(task, new Date()) runtime.result.push(self) @@ -910,8 +892,9 @@ export class ChatLunaAgentTriggerService { task: TriggerTask, patch: Partial ) { - const kind = patch.providerKind ?? task.providerKind - const provider = this._providers.get(kind) + const provider = this._providers.get( + patch.providerKind ?? task.providerKind + ) if (provider == null) return patch const merged = { @@ -944,8 +927,7 @@ export class ChatLunaAgentTriggerService { function hasMessage(input: { wakeupTemplate?: { message?: string | MessageContentComplex[] } }) { - const m = input.wakeupTemplate?.message - if (m == null) return false - if (typeof m === 'string') return m.trim().length > 0 - return true + const msg = input.wakeupTemplate?.message + if (msg == null) return false + return typeof msg !== 'string' || msg.trim().length > 0 } diff --git a/packages/extension-agent/src/skills/builtin.ts b/packages/extension-agent/src/skills/builtin.ts index 845b6eb65..d26faaa8f 100644 --- a/packages/extension-agent/src/skills/builtin.ts +++ b/packages/extension-agent/src/skills/builtin.ts @@ -9,9 +9,8 @@ import { getSkillsRootPath } from '../config/path' export async function syncBundledSkills(ctx: Context) { const src = join(__dirname, '../resources/skills') const dest = getSkillsRootPath(ctx) - const root = await stat(src).catch(() => undefined) - if (!root?.isDirectory()) { + if (!(await stat(src).catch(() => undefined))?.isDirectory()) { ctx.logger.warn('Bundled skills directory not found') return } @@ -21,32 +20,30 @@ export async function syncBundledSkills(ctx: Context) { const entries = await readdir(src, { withFileTypes: true }).catch(() => []) for (const entry of entries) { - if (!entry.isDirectory()) { - continue - } + if (!entry.isDirectory()) continue const from = join(src, entry.name) - const file = join(from, 'SKILL.md') - const skill = await stat(file).catch(() => undefined) - if (!skill?.isFile()) { + if ( + !( + await stat(join(from, 'SKILL.md')).catch(() => undefined) + )?.isFile() + ) continue - } const to = join(dest, entry.name) const force = entry.name === AGENTCLI_SKILL_NAME - const current = await stat(join(to, 'SKILL.md')).catch(() => undefined) + const exists = ( + await stat(join(to, 'SKILL.md')).catch(() => undefined) + )?.isFile() - if (current?.isFile() && !force) { - continue - } - - const synced = await syncSkillDir(from, to, force && current?.isFile()) + if (exists && !force) continue - if (!synced && force && current?.isFile()) continue + if (!(await syncSkillDir(from, to, force && exists)) && force && exists) + continue - ctx.logger[force && current?.isFile() ? 'debug' : 'info']( - `${force && current?.isFile() ? 'Refreshed' : 'Copied'} bundled skill '${entry.name}' to ${to}` + ctx.logger[force && exists ? 'debug' : 'info']( + `${force && exists ? 'Refreshed' : 'Copied'} bundled skill '${entry.name}' to ${to}` ) } } @@ -54,25 +51,24 @@ export async function syncBundledSkills(ctx: Context) { async function syncSkillDir(from: string, to: string, preserveConfig: boolean) { const files = await collectFiles(from) const current = await collectFiles(to).catch(() => []) - const source = new Set(files) + const src = new Set(files) let changed = false for (const file of files) { if (preserveConfig && file === 'config.json') continue - const src = join(from, file) const dest = join(to, file) - const data = await readFile(src) - const old = await readFile(dest).catch(() => undefined) - if (old?.equals(data)) continue + const data = await readFile(join(from, file)) + if ((await readFile(dest).catch(() => undefined))?.equals(data)) + continue await mkdir(dirname(dest), { recursive: true }) - await copyFile(src, dest) + await copyFile(join(from, file), dest) changed = true } for (const file of current) { - if (source.has(file)) continue + if (src.has(file)) continue if (preserveConfig && file === 'config.json') continue await rm(join(to, file), { force: true }) diff --git a/packages/extension-agent/src/skills/catalog.ts b/packages/extension-agent/src/skills/catalog.ts index de0a89bc1..aafa896ec 100644 --- a/packages/extension-agent/src/skills/catalog.ts +++ b/packages/extension-agent/src/skills/catalog.ts @@ -13,28 +13,25 @@ export function buildSkillCatalog( const skillMap = new Map(skills.map((s) => [s.id, s])) const list = applyShadowing(skills, preferRemote) const localByName = new Map( - list - .filter((item) => item.remote !== true) - .filter((item) => !item.shadowedBy) - .map((item) => [item.name, item]) + list.filter((s) => !s.remote && !s.shadowedBy).map((s) => [s.name, s]) ) const catalog: SkillInfo[] = [] for (const skill of list) { - const base = + const cfg = createSkillItemConfig( configItems[skill.id] ?? - (skill.remote - ? configItems[localByName.get(skill.name)?.id ?? ''] - : undefined) - const cfg = createSkillItemConfig(base) - const mode = cfg.mode + (skill.remote + ? configItems[localByName.get(skill.name)?.id ?? ''] + : undefined) + ) const visible = !skill.shadowedBy && skill.enabled && skill.available && skill.state === 'ready' && cfg.enabled && - mode === 'description' + cfg.mode === 'description' + catalog.push({ id: skill.id, name: skill.name, @@ -46,7 +43,7 @@ export function buildSkillCatalog( scope: skill.scope, state: skill.state, enabled: cfg.enabled, - mode, + mode: cfg.mode, authority: cfg.authority ?? 0, main: cfg.main, chatlunaEnabled: cfg.chatluna, @@ -79,16 +76,9 @@ export function buildSkillCatalog( } for (const [id, item] of Object.entries(configItems)) { - if (skillMap.has(id)) { - continue - } - - if (item.remote) { - continue - } + if (skillMap.has(id) || item.remote) continue const cfg = createSkillItemConfig(item) - const mode = cfg.mode if (!cfg.enabled && cfg.mode !== 'description' && cfg.mode !== 'full') { continue } @@ -104,7 +94,7 @@ export function buildSkillCatalog( scope: 'data', state: 'missing', enabled: cfg.enabled, - mode, + mode: cfg.mode, authority: cfg.authority ?? 0, main: cfg.main, chatlunaEnabled: cfg.chatluna, @@ -126,13 +116,8 @@ export function buildSkillCatalog( } return catalog.sort((a, b) => { - const aPriority = skillMap.get(a.id)?.priority ?? 9999 - const bPriority = skillMap.get(b.id)?.priority ?? 9999 - - if (aPriority !== bPriority) { - return aPriority - bPriority - } - - return a.path.localeCompare(b.path) + const ap = skillMap.get(a.id)?.priority ?? 9999 + const bp = skillMap.get(b.id)?.priority ?? 9999 + return ap !== bp ? ap - bp : a.path.localeCompare(b.path) }) } diff --git a/packages/extension-agent/src/skills/import.ts b/packages/extension-agent/src/skills/import.ts index 094577106..790ee52d1 100644 --- a/packages/extension-agent/src/skills/import.ts +++ b/packages/extension-agent/src/skills/import.ts @@ -13,7 +13,7 @@ import { SkillImportPreviewResult, SkillImportResult } from '../types' -import { scanSkillRoot } from '../skills/scan' +import { ScannedSkill, scanSkillRoot } from '../skills/scan' import { collectFilesRecursive, resolveSafe } from '../utils/fs' export async function previewSkillsImport( @@ -66,7 +66,7 @@ export async function importSkills( source.root, input.type, input.type === 'zip' - ? stripExt(input.name) + ? input.name.replace(/\.[^.]+$/, '') : input.type === 'folder' ? input.name : basename(source.root), @@ -110,13 +110,13 @@ export async function importSkills( replaced: [], diagnostics: [...preview.diagnostics] } - const skillsRoot = getSkillsRootPath(ctx) + const root = getSkillsRootPath(ctx) - await mkdir(skillsRoot, { recursive: true }) + await mkdir(root, { recursive: true }) for (const item of picked) { const dir = join(source.root, item.dir === '.' ? '' : item.dir) - const dest = join(skillsRoot, item.importName) + const dest = join(root, item.importName) const existed = ( await stat(dest).catch(() => undefined) )?.isDirectory() @@ -149,39 +149,16 @@ export async function importSkills( } } -async function previewMaterializedSource( +async function validateSkillItems( ctx: Context, - root: string, - source: SkillImportInput['type'], - target: string, - diagnostics: string[] -): Promise { - const entries = await collectPreviewEntries(root) - const scanned = await scanSkillRoot(root, ctx) - const skills = scanned.map((item): SkillImportPreviewItem => { - const dir = item.dir - .slice(root.length) - .replaceAll('\\', '/') - .replace(/^\/+/, '') - const importName = basename(item.dir) - - return { - dir: dir || '.', - importName, - name: item.name, - description: item.description, - state: item.state, - exists: false, - diagnostics: item.diagnostics - } - }) + skills: SkillImportPreviewItem[] +) { const counts = new Map() - for (const item of skills) { counts.set(item.importName, (counts.get(item.importName) ?? 0) + 1) } - const skillsRoot = getSkillsRootPath(ctx) + const root = getSkillsRootPath(ctx) for (const item of skills) { if ((counts.get(item.importName) ?? 0) > 1) { item.state = 'invalid' @@ -193,9 +170,7 @@ async function previewMaterializedSource( item.exists = ( - await stat(join(skillsRoot, item.importName)).catch( - () => undefined - ) + await stat(join(root, item.importName)).catch(() => undefined) )?.isDirectory() === true if (item.exists) { item.diagnostics = [ @@ -204,15 +179,23 @@ async function previewMaterializedSource( ] } } +} + +function buildPreviewResult( + source: SkillImportInput['type'], + target: string, + root: string, + entries: SkillImportPreviewEntry[], + skills: SkillImportPreviewItem[], + diagnostics: string[] +): SkillImportPreviewResult { const valid = skills.length > 0 && skills.every((item) => item.state === 'ready') const notes = [...diagnostics] if (skills.length < 1) { notes.push('没有找到包含 SKILL.md 的 Skill 目录。') - } - - if (!valid && skills.length > 0) { + } else if (!valid) { notes.push('至少有一个 Skill 目录校验失败。') } @@ -226,6 +209,50 @@ async function previewMaterializedSource( } } +function scannedToPreviewItems( + scanned: ScannedSkill[], + root: string +): SkillImportPreviewItem[] { + return scanned.map((item) => { + const rel = item.dir + .slice(root.length) + .replaceAll('\\', '/') + .replace(/^\/+/, '') + + return { + dir: rel || '.', + importName: basename(item.dir), + name: item.name, + description: item.description, + state: item.state, + exists: false, + diagnostics: item.diagnostics + } + }) +} + +async function previewMaterializedSource( + ctx: Context, + root: string, + source: SkillImportInput['type'], + target: string, + diagnostics: string[] +): Promise { + const entries = await collectPreviewEntries(root) + const scanned = await scanSkillRoot(root, ctx) + const skills = scannedToPreviewItems(scanned, root) + await validateSkillItems(ctx, skills) + + return buildPreviewResult( + source, + target, + root, + entries, + skills, + diagnostics + ) +} + async function materializeImportSource( ctx: Context, input: SkillImportInput, @@ -236,24 +263,22 @@ async function materializeImportSource( } if (input.type === 'zip') { - const root = join(tmp, stripExt(input.name) || 'archive') + const root = join(tmp, input.name.replace(/\.[^.]+$/, '') || 'archive') await mkdir(root, { recursive: true }) await unzipToDir(Buffer.from(input.data, 'base64'), root) const files = await collectFilesRecursive(root, { relative: true }) - const top = Array.from( + const tops = Array.from( new Set( files - .map((file) => file.replaceAll('\\', '/').split('/')[0]) + .map((f) => f.replaceAll('\\', '/').split('/')[0]) .filter(Boolean) ) ) - if (top.length === 1) { - const dir = join(root, top[0]) - const info = await stat(dir).catch(() => undefined) - - if (info?.isDirectory()) { + if (tops.length === 1) { + const dir = join(root, tops[0]) + if ((await stat(dir).catch(() => undefined))?.isDirectory()) { return { root: dir, diagnostics: [] } } } @@ -264,14 +289,12 @@ async function materializeImportSource( const root = join(tmp, input.name || 'folder') await mkdir(root, { recursive: true }) - for (const file of input.files) { - const target = resolveSafe(root, file.path) - if (!target) { - continue - } + for (const f of input.files) { + const target = resolveSafe(root, f.path) + if (!target) continue await mkdir(dirname(target), { recursive: true }) - await writeFile(target, Buffer.from(file.data, 'base64')) + await writeFile(target, Buffer.from(f.data, 'base64')) } return { root, diagnostics: [] } @@ -309,10 +332,7 @@ async function previewGithub(ctx: Context, url: string) { })) .filter((item) => item.path.length > 0) .filter((item) => { - if (!info.subpath) { - return true - } - + if (!info.subpath) return true return ( item.path === info.subpath || item.path.startsWith(`${info.subpath}/`) @@ -326,10 +346,7 @@ async function previewGithub(ctx: Context, url: string) { })) .filter((item) => item.path.length > 0) .sort((a, b) => { - if (a.path === b.path) { - return a.type === 'directory' ? -1 : 1 - } - + if (a.path === b.path) return a.type === 'directory' ? -1 : 1 return a.path.localeCompare(b.path) }) @@ -352,11 +369,11 @@ async function previewGithub(ctx: Context, url: string) { .map((item) => item.path) ) const needed = new Set( - [...files].filter((item) => basename(item) === 'SKILL.md') + [...files].filter((f) => basename(f) === 'SKILL.md') ) - for (const file of needed) { - const dir = dirname(file) + for (const f of needed) { + const dir = dirname(f) const extra = dir === '.' ? 'agents/openai.yaml' : `${dir}/agents/openai.yaml` if (files.has(extra)) { @@ -365,18 +382,16 @@ async function previewGithub(ctx: Context, url: string) { } await Promise.all( - [...needed].map(async (file) => { + [...needed].map(async (f) => { const content = await fetchGithubFile( ctx, info.owner, info.repo, - info.subpath ? `${info.subpath}/${file}` : file, + info.subpath ? `${info.subpath}/${f}` : f, ref ) - const target = resolveSafe(tmp, file) - if (!target) { - return - } + const target = resolveSafe(tmp, f) + if (!target) return await mkdir(dirname(target), { recursive: true }) await writeFile(target, content, 'utf-8') @@ -387,71 +402,17 @@ async function previewGithub(ctx: Context, url: string) { ? `${info.owner}/${info.repo}/${info.subpath}` : `${info.owner}/${info.repo}` const scanned = await scanSkillRoot(tmp, ctx) - const skills = scanned.map((item): SkillImportPreviewItem => { - const dir = - item.dir - .slice(tmp.length) - .replaceAll('\\', '/') - .replace(/^\/+/, '') || '.' - - return { - dir, - importName: basename(item.dir), - name: item.name, - description: item.description, - state: item.state, - exists: false, - diagnostics: item.diagnostics - } - }) - const counts = new Map() - - for (const item of skills) { - counts.set(item.importName, (counts.get(item.importName) ?? 0) + 1) - } - - const skillsRoot = getSkillsRootPath(ctx) - for (const item of skills) { - if ((counts.get(item.importName) ?? 0) > 1) { - item.state = 'invalid' - item.diagnostics = [ - `重复的导入目录名:${item.importName}`, - ...item.diagnostics - ] - } - - item.exists = - ( - await stat(join(skillsRoot, item.importName)).catch( - () => undefined - ) - )?.isDirectory() === true - if (item.exists) { - item.diagnostics = [ - `将覆盖现有 Skill:${item.importName}`, - ...item.diagnostics - ] - } - } - const valid = - skills.length > 0 && skills.every((item) => item.state === 'ready') - - if (skills.length < 1) { - diagnostics.push('没有找到包含 SKILL.md 的 Skill 目录。') - } - - if (!valid && skills.length > 0) { - diagnostics.push('至少有一个 Skill 目录校验失败。') - } + const skills = scannedToPreviewItems(scanned, tmp) + await validateSkillItems(ctx, skills) - return { - source: 'github', + return buildPreviewResult( + 'github', target, - valid, + tmp, entries, skills, diagnostics - } satisfies SkillImportPreviewResult + ) } finally { await rm(tmp, { recursive: true, force: true }).catch(() => {}) } @@ -459,12 +420,11 @@ async function previewGithub(ctx: Context, url: string) { async function importFromGithub(ctx: Context, url: string, tmp: string) { const info = parseGithubUrl(url) - const diagnostics: string[] = [] - if (!info) { throw new Error('Unsupported GitHub URL') } + const diagnostics: string[] = [] const root = join(tmp, `${info.owner}-${info.repo}`) await mkdir(root, { recursive: true }) @@ -480,7 +440,7 @@ async function importFromGithub(ctx: Context, url: string, tmp: string) { const files = await collectFilesRecursive(root, { relative: true }) const tops = Array.from( - new Set(files.map((file) => file.replaceAll('\\', '/').split('/')[0])) + new Set(files.map((f) => f.replaceAll('\\', '/').split('/')[0])) ).filter(Boolean) const base = tops.length === 1 && @@ -488,20 +448,17 @@ async function importFromGithub(ctx: Context, url: string, tmp: string) { ? join(root, tops[0]) : root - const searchRoot = info.subpath + const sub = info.subpath ? await findSubpathRoot(base, info.subpath) : undefined - if (info.subpath && !searchRoot) { + if (info.subpath && !sub) { diagnostics.push( `GitHub 子路径 '${info.subpath}' 不存在,已回退到整个仓库继续扫描。` ) } - return { - root: searchRoot ?? base, - diagnostics - } + return { root: sub ?? base, diagnostics } } async function collectPreviewEntries( @@ -510,21 +467,20 @@ async function collectPreviewEntries( const files = await collectFilesRecursive(root, { relative: true }) const dirs = new Set() - for (const file of files) { - let current = dirname(file) - - while (current && current !== '.') { - dirs.add(current.replaceAll('\\', '/')) - current = dirname(current) + for (const f of files) { + let cur = dirname(f) + while (cur && cur !== '.') { + dirs.add(cur.replaceAll('\\', '/')) + cur = dirname(cur) } } return [ ...[...dirs] .sort((a, b) => a.localeCompare(b)) - .map((path) => ({ path, type: 'directory' as const })), - ...files.map((path) => ({ - path: path.replaceAll('\\', '/'), + .map((p) => ({ path: p, type: 'directory' as const })), + ...files.map((p) => ({ + path: p.replaceAll('\\', '/'), type: 'file' as const })) ] @@ -535,9 +491,7 @@ async function unzipToDir(buffer: Buffer, root: string) { for (const [name, value] of Object.entries(files)) { const target = resolveSafe(root, name) - if (!target || name.endsWith('/')) { - continue - } + if (!target || name.endsWith('/')) continue await mkdir(dirname(target), { recursive: true }) await writeFile(target, Buffer.from(value)) @@ -546,9 +500,7 @@ async function unzipToDir(buffer: Buffer, root: string) { async function findSubpathRoot(root: string, subpath: string) { const clean = subpath.replaceAll('\\', '/').replace(/^\/+|\/+$/g, '') - if (clean.length < 1) { - return root - } + if (clean.length < 1) return root const direct = join(root, clean) if ((await stat(direct).catch(() => undefined))?.isDirectory()) { @@ -557,7 +509,7 @@ async function findSubpathRoot(root: string, subpath: string) { const entries = await collectFilesRecursive(root, { relative: true }) const tops = Array.from( - new Set(entries.map((file) => file.replaceAll('\\', '/').split('/')[0])) + new Set(entries.map((f) => f.replaceAll('\\', '/').split('/')[0])) ).filter(Boolean) for (const name of tops) { @@ -579,12 +531,10 @@ async function fetchGithubDefaultBranch( ctx, `https://api.github.com/repos/${owner}/${repo}` ) - const branch = String(response.data?.default_branch ?? '').trim() if (!branch) { throw new Error('GitHub 仓库没有默认分支。') } - return branch } @@ -599,12 +549,13 @@ async function fetchGithubFile( ctx, `https://api.github.com/repos/${owner}/${repo}/contents/${path .split('/') - .map((item) => encodeURIComponent(item)) + .map((s) => encodeURIComponent(s)) .join('/')}?ref=${encodeURIComponent(ref)}` ) - const content = String(response.data?.content ?? '').replace(/\n/g, '') - - return Buffer.from(content, 'base64').toString('utf-8') + return Buffer.from( + String(response.data?.content ?? '').replace(/\n/g, ''), + 'base64' + ).toString('utf-8') } function githubHeaders(ctx: Context) { @@ -612,12 +563,10 @@ function githubHeaders(ctx: Context) { Accept: 'application/vnd.github+json', 'User-Agent': 'ChatLuna-Agent' } - const token = ctx.chatluna_agent?.args.config.skills.githubToken?.trim() if (token) { headers.Authorization = `Bearer ${token}` } - return headers } @@ -640,90 +589,63 @@ async function requestGithub( } function getGithubError(err: unknown) { - const value = err as { + const e = err as { response?: { status?: number } status?: number statusCode?: number message?: string } - const status = Number( - value.response?.status ?? value.status ?? value.statusCode ?? 0 - ) - const msg = String(value.message ?? err ?? '').trim() + const status = Number(e.response?.status ?? e.status ?? e.statusCode ?? 0) + const msg = String(e.message ?? err ?? '').trim() if (status === 401 || /bad credentials/i.test(msg)) { return 'GitHub Token 无效或已过期,请检查后重试。' } - if (status === 403 && /rate limit/i.test(msg)) { return 'GitHub API 已触发限流,请稍后重试,或先在导入弹窗里配置 GitHub Token。' } - - if (/rate limit/i.test(msg)) { - return 'GitHub API 已触发限流,请稍后重试,或先在导入弹窗里配置 GitHub Token。' - } - if (status === 403) { return 'GitHub 拒绝了当前请求,请检查仓库权限或 Token 配置。' } - if (status === 404) { return 'GitHub 地址不存在,或当前分支、目录无法访问。' } - if (msg) { return `GitHub 请求失败:${msg}` } - return 'GitHub 请求失败,请稍后重试。' } function parseGithubUrl(url: string) { let parsed: URL - try { parsed = new URL(url) } catch { return undefined } - if (parsed.hostname !== 'github.com') { - return undefined - } + if (parsed.hostname !== 'github.com') return undefined const parts = parsed.pathname .replace(/\.git$/, '') .split('/') .filter(Boolean) - if (parts.length < 2) { - return undefined - } - - const owner = parts[0] - const repo = parts[1] - - if (owner.length < 1 || repo.length < 1) { - return undefined - } + if (parts.length < 2 || !parts[0] || !parts[1]) return undefined if (parts[2] === 'tree' && parts[3]) { return { - owner, - repo, + owner: parts[0], + repo: parts[1], ref: parts[3], subpath: parts.slice(4).join('/') } } return { - owner, - repo, + owner: parts[0], + repo: parts[1], ref: '', subpath: '' } } - -function stripExt(name: string) { - return name.replace(/\.[^.]+$/, '') -} diff --git a/packages/extension-agent/src/skills/manage.ts b/packages/extension-agent/src/skills/manage.ts index e1ad0dcbd..0dd5193e4 100644 --- a/packages/extension-agent/src/skills/manage.ts +++ b/packages/extension-agent/src/skills/manage.ts @@ -11,40 +11,37 @@ export async function exportSkillArchive( id: string, dir: string ): Promise { - const info = await stat(dir).catch(() => undefined) - if (!info?.isDirectory()) { + if (!(await stat(dir).catch(() => undefined))?.isDirectory()) { throw new Error('Skill directory was not found') } const name = basename(dir) - const files = await collectFilesRecursive(dir) - const archive = zipSync( - Object.fromEntries( - await Promise.all( - files.map(async (file) => { - const rel = relative(dir, file).replaceAll('\\', '/') - return [`${name}/${rel}`, await readFile(file)] - }) - ) - ) - ) return { id, name, fileName: `${name}.zip`, - data: Buffer.from(archive).toString('base64') + data: Buffer.from( + zipSync( + Object.fromEntries( + await Promise.all( + (await collectFilesRecursive(dir)).map(async (file) => [ + `${name}/${relative(dir, file).replaceAll('\\', '/')}`, + await readFile(file) + ]) + ) + ) + ) + ).toString('base64') } } export async function removeSkillDirectory(root: string, dir: string) { - const target = resolve(dir) - - if (!isPathInside(target, root)) { + if (!isPathInside(resolve(dir), root)) { throw new Error( 'Only skills inside data/chatluna/skills can be removed' ) } - await rm(target, { recursive: true, force: true }) + await rm(resolve(dir), { recursive: true, force: true }) } diff --git a/packages/extension-agent/src/skills/render.ts b/packages/extension-agent/src/skills/render.ts index a3520c955..c0ed4ae78 100644 --- a/packages/extension-agent/src/skills/render.ts +++ b/packages/extension-agent/src/skills/render.ts @@ -29,7 +29,7 @@ export function renderAvailableSkills( ) } - if (skills.length > 0) { + if (skills.length) { lines.push( 'You can load extra instructions with the skill tool when the current task matches one of the skills below.', 'Use a skill early when it gives you a better workflow, checklist, or domain-specific procedure.', @@ -49,7 +49,7 @@ export function renderAvailableSkills( } } - if (active.length > 0) { + if (active.length) { lines.push('', '') for (const skill of active) { @@ -69,7 +69,7 @@ export function renderAvailableSkills( ) } - if (skills.length > 0) { + if (skills.length) { lines.push('', 'Use the exact skill name when calling the skill tool.') } @@ -81,19 +81,19 @@ export function renderAvailableSkills( export async function renderSkillContent( skill: ScannedSkill, loaded = false, - options: { + opts: { skillDir?: string resources?: string[] } = {} ) { - const resources = options.resources ?? (await listSkillResources(skill.dir)) + const res = opts.resources ?? (await listSkillResources(skill.dir)) const lines = [ ``, loaded ? 'The following skill is now active for the current conversation.' : 'The following skill remains active for the current conversation.', `Description: ${skill.description}`, - ...(options.skillDir ? [`Directory: ${options.skillDir}`] : []), + ...(opts.skillDir ? [`Directory: ${opts.skillDir}`] : []), ...(skill.homepage ? [`Homepage: ${skill.homepage}`] : []), ...(skill.requires ? [ @@ -115,23 +115,21 @@ export async function renderSkillContent( .join(' | ')}` ] : []), - ...(skill.install && skill.install.length > 0 + ...(skill.install?.length ? [ `Install options: ${skill.install.map((item) => item.label ?? item.id).join('; ')}` ] : []), - ...(skill.allowedTools && skill.allowedTools.length > 0 + ...(skill.allowedTools?.length ? [`Allowed tools: ${skill.allowedTools.join(', ')}`] : []), '', - skill.body.length > 0 ? skill.body : skill.raw, + skill.body.length ? skill.body : skill.raw, '', - ...(resources.length > 0 + ...(res.length ? [ '', - ...resources.map( - (file) => ` ${escapeXml(file)}` - ), + ...res.map((file) => ` ${escapeXml(file)}`), '' ] : []), diff --git a/packages/extension-agent/src/skills/scan.ts b/packages/extension-agent/src/skills/scan.ts index 3d5cda83a..acbdbd485 100644 --- a/packages/extension-agent/src/skills/scan.ts +++ b/packages/extension-agent/src/skills/scan.ts @@ -19,7 +19,7 @@ import { import { collectFilesRecursive } from '../utils/fs' import { extractFrontmatter } from '../utils/frontmatter' import { createHashId } from '../utils/id' -import { isPathInside, resolveTildeDir, toPathKey } from '../utils/path' +import { expandDir, isPathInside, toPathKey } from '../utils/path' import { quoteShellPath } from '../utils/shell' const execFileAsync = promisify(execFile) @@ -84,17 +84,16 @@ export async function scanSkills( ): Promise { const targets = await getScanTargets(ctx, cfg.skills) const bins = new Map() - const skills = ( - await Promise.all( - targets.map((target) => scanTarget(ctx, target, cfg, bins)) - ) - ).flat() - return skills.sort((a, b) => - a.priority !== b.priority - ? a.priority - b.priority - : a.path.localeCompare(b.path) + return ( + await Promise.all(targets.map((t) => scanTarget(ctx, t, cfg, bins))) ) + .flat() + .sort((a, b) => + a.priority !== b.priority + ? a.priority - b.priority + : a.path.localeCompare(b.path) + ) } export async function getSkillRoots(ctx: Context, cfg: AgentConfig['skills']) { @@ -110,12 +109,12 @@ export async function scanSkillRoot( const dirs = Array.from( new Set( files - .filter((file) => basename(file) === 'SKILL.md') - .map((file) => dirname(file)) + .filter((f) => basename(f) === 'SKILL.md') + .map((f) => dirname(f)) ) ).sort((a, b) => a.localeCompare(b)) - return await Promise.all( + return Promise.all( dirs.map((dir) => parseSkill( join(dir, 'SKILL.md'), @@ -126,10 +125,7 @@ export async function scanSkillRoot( priority: 0, remote: false }, - { - dirs: [], - items: {} - }, + { dirs: [], items: {} }, undefined, bins, ctx @@ -139,7 +135,7 @@ export async function scanSkillRoot( } export async function listSkillResources(dir: string): Promise { - return await collectFilesRecursive(dir, { + return collectFilesRecursive(dir, { limit: 200, excludeNames: ['SKILL.md'], relative: true @@ -169,7 +165,7 @@ export async function listRemoteSkillResources( return result.stdout .split('\n') - .map((item) => item.trim()) + .map((s) => s.trim()) .filter(Boolean) .sort((a, b) => a.localeCompare(b)) } @@ -180,20 +176,20 @@ async function scanTarget( cfg: AgentConfig, bins: Map ): Promise { - const root = await stat(target.root).catch(() => undefined) - if (!root?.isDirectory()) return [] + const info = await stat(target.root).catch(() => undefined) + if (!info?.isDirectory()) return [] const entries = await readdir(target.root, { withFileTypes: true }) const skills = await Promise.all( entries.map(async (entry) => { const file = join(target.root, entry.name, 'SKILL.md') - const info = await stat(file).catch(() => undefined) - if (!info?.isFile()) return undefined + const fi = await stat(file).catch(() => undefined) + if (!fi?.isFile()) return undefined return parseSkill(file, target, cfg.skills, cfg, bins, ctx) }) ) - return skills.filter((skill): skill is ScannedSkill => skill != null) + return skills.filter((s): s is ScannedSkill => s != null) } async function parseSkill( @@ -207,7 +203,7 @@ async function parseSkill( const dir = dirname(file) const raw = await readFile(file, 'utf-8').catch(() => '') - return await parseSkillText({ + return parseSkillText({ file, dir, target, @@ -235,7 +231,6 @@ async function parseSkillText(input: { extra?: string }): Promise { const diagnostics: string[] = [] - const fallbackName = basename(input.dir) if (!input.raw) { return createInvalidSkill({ @@ -290,7 +285,7 @@ async function parseSkillText(input: { const name = typeof frontmatter.name === 'string' && frontmatter.name ? frontmatter.name - : fallbackName + : basename(input.dir) const description = typeof frontmatter.description === 'string' ? frontmatter.description.trim() @@ -308,25 +303,17 @@ async function parseSkillText(input: { diagnostics.push('Skill description is required') } - const metadata = pickMetadata(frontmatter.metadata) const allowedTools = parseAllowedTools(frontmatter['allowed-tools']) - const availableResult = await checkAvailability( + const availability = await checkAvailability( openclaw, input.agentCfg, input.bins, input.ctx ) - const implicitInvocation = - frontmatter['disable-model-invocation'] === true - ? false - : extra.allowImplicitInvocation !== false - const userInvocable = frontmatter['user-invocable'] !== false - const id = createSkillId(input.file) + const id = createHashId(input.file) const mode = input.cfg.items[id]?.mode ?? 'description' - const enabled = mode !== 'off' - const state: SkillState = description ? 'ready' : 'invalid' - diagnostics.push(...availableResult.diagnostics) + diagnostics.push(...availability.diagnostics) return { id, @@ -337,11 +324,14 @@ async function parseSkillText(input: { source: input.target.source, scope: input.target.scope, remote: input.target.remote, - state, - enabled, - available: availableResult.available, - userInvocable, - implicitInvocation, + state: description ? 'ready' : 'invalid', + enabled: mode !== 'off', + available: availability.available, + userInvocable: frontmatter['user-invocable'] !== false, + implicitInvocation: + frontmatter['disable-model-invocation'] === true + ? false + : extra.allowImplicitInvocation !== false, emoji: openclaw.emoji, homepage: typeof frontmatter.homepage === 'string' @@ -357,9 +347,9 @@ async function parseSkillText(input: { typeof frontmatter.license === 'string' ? frontmatter.license : undefined, - metadata, + metadata: pickMetadata(frontmatter.metadata), requires: openclaw.requires, - install: availableResult.install, + install: availability.install, allowedTools, diagnostics, body: parsed.body, @@ -377,8 +367,7 @@ function createInvalidSkill(input: { raw: string body: string }): ScannedSkill { - const id = createSkillId(input.file) - const mode = input.cfg.items[id]?.mode ?? 'description' + const id = createHashId(input.file) return { id, @@ -390,7 +379,7 @@ function createInvalidSkill(input: { scope: input.target.scope, remote: input.target.remote, state: 'invalid', - enabled: mode !== 'off', + enabled: (input.cfg.items[id]?.mode ?? 'description') !== 'off', available: false, userInvocable: true, implicitInvocation: false, @@ -405,9 +394,7 @@ function parseExtraMetadata(content?: string): { allowImplicitInvocation?: boolean diagnostics: string[] } { - const diagnostics: string[] = [] - - if (!content) return { diagnostics } + if (!content) return { diagnostics: [] } try { const extra = (load(content) as Record) ?? {} @@ -416,24 +403,21 @@ function parseExtraMetadata(content?: string): { return { allowImplicitInvocation: policy?.allow_implicit_invocation === false ? false : undefined, - diagnostics + diagnostics: [] } } catch (error) { - diagnostics.push( - `Failed to parse agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}` - ) - return { diagnostics } + return { + diagnostics: [ + `Failed to parse agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}` + ] + } } } function parseAllowedTools(value: unknown) { if (typeof value !== 'string' || !value.trim()) return undefined - const items = value - .split(/\s*,\s*|\s+/) - .map((item) => item.trim()) - .filter(Boolean) - + const items = value.split(/\s*,\s*|\s+/).filter(Boolean) return items.length > 0 ? items : undefined } @@ -441,11 +425,11 @@ function pickMetadata(value: unknown) { if (typeof value !== 'object' || value == null) return undefined const result = Object.fromEntries( - Object.entries(value).flatMap(([key, item]) => - typeof item === 'string' || - typeof item === 'number' || - typeof item === 'boolean' - ? [[key, String(item)]] + Object.entries(value).flatMap(([k, v]) => + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' + ? [[k, String(v)]] : [] ) ) @@ -461,22 +445,22 @@ function parseOpenClawMetadata(value: unknown): OpenClawMetadata { return { always: false } } - const item = openclaw as Record - const install = Array.isArray(item.install) - ? item.install + const oc = openclaw as Record + const install = Array.isArray(oc.install) + ? oc.install .map((entry) => parseInstallAction(entry)) .filter((entry): entry is SkillInstallAction => entry != null) : undefined return { - always: item.always === true, - emoji: typeof item.emoji === 'string' ? item.emoji : undefined, - homepage: typeof item.homepage === 'string' ? item.homepage : undefined, - skillKey: typeof item.skillKey === 'string' ? item.skillKey : undefined, + always: oc.always === true, + emoji: typeof oc.emoji === 'string' ? oc.emoji : undefined, + homepage: typeof oc.homepage === 'string' ? oc.homepage : undefined, + skillKey: typeof oc.skillKey === 'string' ? oc.skillKey : undefined, primaryEnv: - typeof item.primaryEnv === 'string' ? item.primaryEnv : undefined, - os: parseStringList(item.os), - requires: parseRequires(item.requires), + typeof oc.primaryEnv === 'string' ? oc.primaryEnv : undefined, + os: parseStringList(oc.os), + requires: parseRequires(oc.requires), install: install?.length ? install : undefined } } @@ -484,44 +468,41 @@ function parseOpenClawMetadata(value: unknown): OpenClawMetadata { function parseRequires(value: unknown) { if (typeof value !== 'object' || value == null) return undefined - const item = value as Record + const v = value as Record const result: SkillRequires = { - bins: parseStringList(item.bins), - anyBins: parseStringList(item.anyBins), - env: parseStringList(item.env), - config: parseStringList(item.config) + bins: parseStringList(v.bins), + anyBins: parseStringList(v.anyBins), + env: parseStringList(v.env), + config: parseStringList(v.config) } - return Object.values(result).some((entry) => entry?.length) - ? result - : undefined + return Object.values(result).some((e) => e?.length) ? result : undefined } function parseInstallAction(value: unknown): SkillInstallAction | undefined { if (typeof value !== 'object' || value == null) return undefined - const item = value as Record - if (typeof item.id !== 'string' || typeof item.kind !== 'string') { + const v = value as Record + if (typeof v.id !== 'string' || typeof v.kind !== 'string') { return undefined } return { - id: item.id, - kind: item.kind, - label: typeof item.label === 'string' ? item.label : undefined, - bins: parseStringList(item.bins), - os: parseStringList(item.os), - formula: typeof item.formula === 'string' ? item.formula : undefined, - package: typeof item.package === 'string' ? item.package : undefined, - url: typeof item.url === 'string' ? item.url : undefined, - archive: typeof item.archive === 'string' ? item.archive : undefined, - extract: typeof item.extract === 'boolean' ? item.extract : undefined, + id: v.id, + kind: v.kind, + label: typeof v.label === 'string' ? v.label : undefined, + bins: parseStringList(v.bins), + os: parseStringList(v.os), + formula: typeof v.formula === 'string' ? v.formula : undefined, + package: typeof v.package === 'string' ? v.package : undefined, + url: typeof v.url === 'string' ? v.url : undefined, + archive: typeof v.archive === 'string' ? v.archive : undefined, + extract: typeof v.extract === 'boolean' ? v.extract : undefined, stripComponents: - typeof item.stripComponents === 'number' - ? item.stripComponents + typeof v.stripComponents === 'number' + ? v.stripComponents : undefined, - targetDir: - typeof item.targetDir === 'string' ? item.targetDir : undefined + targetDir: typeof v.targetDir === 'string' ? v.targetDir : undefined } } @@ -530,36 +511,35 @@ function parseStringList(value: unknown) { const result = value .map(String) - .map((item) => item.trim()) + .map((s) => s.trim()) .filter(Boolean) - return result.length ? result : undefined } async function checkAvailability( - metadata: OpenClawMetadata, + meta: OpenClawMetadata, cfg?: AgentConfig, bins = new Map(), ctx?: Context ) { const diagnostics: string[] = [] - const install = metadata.install?.filter( + const install = meta.install?.filter( (item) => !item.os || item.os.includes(process.platform) ) - if (metadata.always) { + if (meta.always) { return { available: true, diagnostics, install } } - if (metadata.os && !metadata.os.includes(process.platform)) { + if (meta.os && !meta.os.includes(process.platform)) { diagnostics.push( - `Unsupported OS: ${process.platform} (requires ${metadata.os.join(', ')})` + `Unsupported OS: ${process.platform} (requires ${meta.os.join(', ')})` ) } - if (metadata.requires?.bins?.length) { + if (meta.requires?.bins?.length) { const missing: string[] = [] - for (const bin of metadata.requires.bins) { + for (const bin of meta.requires.bins) { if (!(await hasBin(bin, bins, ctx))) missing.push(bin) } if (missing.length) { @@ -567,9 +547,9 @@ async function checkAvailability( } } - if (metadata.requires?.anyBins?.length) { + if (meta.requires?.anyBins?.length) { let matched = false - for (const bin of metadata.requires.anyBins) { + for (const bin of meta.requires.anyBins) { if (await hasBin(bin, bins, ctx)) { matched = true break @@ -577,13 +557,13 @@ async function checkAvailability( } if (!matched) { diagnostics.push( - `Need one available binary: ${metadata.requires.anyBins.join(', ')}` + `Need one available binary: ${meta.requires.anyBins.join(', ')}` ) } } - if (metadata.requires?.env?.length) { - const missing = metadata.requires.env.filter( + if (meta.requires?.env?.length) { + const missing = meta.requires.env.filter( (key) => !process.env[key]?.trim() ) if (missing.length) { @@ -591,8 +571,8 @@ async function checkAvailability( } } - if (metadata.requires?.config?.length) { - const missing = metadata.requires.config.filter( + if (meta.requires?.config?.length) { + const missing = meta.requires.config.filter( (key) => !hasConfigPath(cfg, key) ) if (missing.length) { @@ -691,7 +671,7 @@ async function getScanTargets( const item = dirs[idx].trim() if (!item) continue - const dir = resolveTildeDir(ctx.baseDir, item) + const dir = expandDir(ctx.baseDir, item) const key = toPathKey(dir) if (seen.has(key)) continue @@ -708,30 +688,26 @@ async function getScanTargets( return targets } +const SOURCE_PATTERNS: [string, SkillSource][] = [ + ['/claude/skills', 'claude'], + ['/openclaw/skills', 'openclaw'], + ['/agents/skills', 'universal'], + ['/codex/skills', 'codex'], + ['/opencode/skills', 'opencode'] +] + function detectSkillSource(raw: string, dir: string): SkillSource { const value = `${raw}\n${dir}`.replaceAll('\\', '/').toLowerCase() - if (value.includes('/.claude/skills') || value.endsWith('/claude/skills')) { - return 'claude' - } - if ( - value.includes('/.openclaw/skills') || - value.endsWith('/openclaw/skills') - ) { - return 'openclaw' - } - if (value.includes('/.agents/skills') || value.endsWith('/agents/skills')) { - return 'universal' - } - if (value.includes('/.codex/skills') || value.endsWith('/codex/skills')) { - return 'codex' - } - if ( - value.includes('/.opencode/skills') || - value.endsWith('/opencode/skills') - ) { - return 'opencode' + for (const [pattern, source] of SOURCE_PATTERNS) { + if ( + value.includes(`/.${pattern.slice(1)}`) || + value.endsWith(pattern) + ) { + return source + } } + return 'custom' } @@ -740,7 +716,3 @@ function detectSkillScope(ctx: Context, dir: string): SkillScope { if (isPathInside(dir, ctx.baseDir)) return 'project' return 'user' } - -function createSkillId(file: string) { - return createHashId(file) -} diff --git a/packages/extension-agent/src/skills/slash.ts b/packages/extension-agent/src/skills/slash.ts index f9f8a291d..13a5dcd09 100644 --- a/packages/extension-agent/src/skills/slash.ts +++ b/packages/extension-agent/src/skills/slash.ts @@ -3,18 +3,16 @@ import { HumanMessage } from '@langchain/core/messages' import { getMessageContent } from 'koishi-plugin-chatluna/utils/string' -const skillSlashRe = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)/i +const slashRe = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)/i export function getSlashSkillName(message: HumanMessage) { - const text = getMessageContent(message.content) - const match = text.match(skillSlashRe) - return match?.[1].toLowerCase() + return getMessageContent(message.content).match(slashRe)?.[1].toLowerCase() } export function stripSlashSkillName(message: HumanMessage) { if (typeof message.content === 'string') { message.content = message.content - .replace(skillSlashRe, '') + .replace(slashRe, '') .replace(/^\s+/, '') } else if (Array.isArray(message.content)) { let done = false @@ -23,16 +21,14 @@ export function stripSlashSkillName(message: HumanMessage) { done || part.type !== 'text' || typeof part.text !== 'string' || - !skillSlashRe.test(part.text) + !slashRe.test(part.text) ) { return part } - done = true - return { ...part, - text: part.text.replace(skillSlashRe, '').replace(/^\s+/, '') + text: part.text.replace(slashRe, '').replace(/^\s+/, '') } }) } @@ -40,7 +36,7 @@ export function stripSlashSkillName(message: HumanMessage) { const raw = getMessageContent(message.content) if (typeof raw === 'string') { message.additional_kwargs['raw_content'] = raw - .replace(skillSlashRe, '') + .replace(slashRe, '') .replace(/^\s+/, '') } } diff --git a/packages/extension-agent/src/skills/tool.ts b/packages/extension-agent/src/skills/tool.ts index bd7a01e24..f32f74035 100644 --- a/packages/extension-agent/src/skills/tool.ts +++ b/packages/extension-agent/src/skills/tool.ts @@ -19,11 +19,11 @@ export class SkillTool extends StructuredTool { this.description = service.buildToolDescription() } - async _call( + _call( input: z.infer, _: unknown, runConfig?: ChatLunaToolRunnable ) { - return await this.service.activateSkill(input.name, runConfig) + return this.service.activateSkill(input.name, runConfig) } } diff --git a/packages/extension-agent/src/skills/watch.ts b/packages/extension-agent/src/skills/watch.ts index 58c010c15..26e2921f6 100644 --- a/packages/extension-agent/src/skills/watch.ts +++ b/packages/extension-agent/src/skills/watch.ts @@ -1,5 +1,3 @@ -/** @module skills/watch */ - import type { FSWatcher } from 'fs' import { watch } from 'fs' import { readdir, stat } from 'fs/promises' @@ -17,9 +15,17 @@ export async function watchSkillFiles( const roots = await getSkillRoots(ctx, cfg) const recursive = process.platform === 'win32' || process.platform === 'darwin' - const dirs = recursive - ? await filterExisting(roots) - : await getAllDirs(roots) + + const dirs: string[] = [] + if (recursive) { + for (const r of roots) { + if ((await stat(r).catch(() => undefined))?.isDirectory()) { + dirs.push(r) + } + } + } else { + dirs.push(...(await getAllDirs(roots))) + } const watchers: FSWatcher[] = [] let timer: NodeJS.Timeout | undefined @@ -75,23 +81,15 @@ export async function watchSkillFiles( } } -async function filterExisting(roots: string[]) { - const dirs: string[] = [] - for (const dir of roots) { - const info = await stat(dir).catch(() => undefined) - if (info?.isDirectory()) dirs.push(dir) - } - return dirs -} - async function getAllDirs(roots: string[]) { const seen = new Set() const queue: string[] = [] const dirs: string[] = [] - for (const dir of roots) { - const info = await stat(dir).catch(() => undefined) - if (info?.isDirectory()) queue.push(dir) + for (const r of roots) { + if ((await stat(r).catch(() => undefined))?.isDirectory()) { + queue.push(r) + } } while (queue.length) { diff --git a/packages/extension-agent/src/sub-agent/builtin.ts b/packages/extension-agent/src/sub-agent/builtin.ts index b8c50c5f0..61b467011 100644 --- a/packages/extension-agent/src/sub-agent/builtin.ts +++ b/packages/extension-agent/src/sub-agent/builtin.ts @@ -46,69 +46,42 @@ function createBuiltin( } } -// --------------------------------------------------------------------------- -// Builtin agent system prompts -// --------------------------------------------------------------------------- - -const PLAN_PROMPT = [ - 'You are the Plan sub-agent. Your job is to analyze code and come up with implementation plans. You can only read, not write.', - '', - 'Tools you have:', - 'file_read — read file contents or list a directory. Use offset/limit for large files.', - 'grep — search file contents with regex. Use include patterns to narrow things down (e.g. "*.ts").', - 'glob — find files by pattern (e.g. "src/**/*.ts").', - '', - "You don't have write access. Don't try to use file_write, file_edit, or bash.", - '', - "Start by understanding what's being asked, then go read the relevant code, " + - 'search for related logic, and trace through call chains and constraints. ' + - 'Once you have a clear picture, put together a concrete plan covering ' + - 'which files need to change, what the key code changes look like, any ' + - 'compatibility concerns, and how to test it.', - '', - 'Be specific — include file paths, function names, and the order things ' + - 'should happen in. If something is unclear or needs a human decision, ' + - 'say so.' -].join('\n') - -const GENERAL_PROMPT = [ - 'You are the General sub-agent. You handle implementation tasks from start to finish.', - '', - 'Tools you have:', - 'file_read — read file contents or list a directory. Use offset/limit for large files.', - 'file_write — create or overwrite files. Parent directories are created automatically.', - 'file_edit — make targeted replacements in existing files. Use this over file_write for small changes.', - 'grep — search file contents with regex.', - 'glob — find files by pattern.', - 'bash — run shell commands for building, testing, scripts, git, etc.', - '', - 'Read the relevant code first to understand context before changing ' + - 'anything. For small edits use file_edit; only use file_write for new ' + - 'files or full rewrites. After making changes, run the build or tests ' + - 'to make sure things still work, then summarize what you changed and ' + - 'why.', - '', - "Always read a file before editing it — don't guess at contents. Keep " + - "changes focused and don't refactor unrelated code. If the task " + - 'touches many files, work through them one at a time. Use safe shell ' + - "commands; avoid rm -rf or force operations unless you're told to." -].join('\n') - -const EXPLORE_PROMPT = [ - 'You are the Explore sub-agent. You quickly gather information from the codebase. You can only read, not write.', - '', - 'Tools you have:', - 'file_read — read file contents or list a directory. Use offset/limit for large files.', - 'grep — search file contents with regex. Use include patterns to narrow things down.', - 'glob — find files by pattern.', - '', - "You don't have write access. Don't try to use file_write, file_edit, or bash.", - '', - 'Start with broad searches using glob and grep to find relevant files, then ' + - 'read them selectively. Follow imports, call sites, and type ' + - 'definitions to piece together how things connect.', - '', - 'Give back exact file paths, line numbers, and code snippets. Stick to ' + - "facts and don't speculate. If there are multiple possible " + - 'interpretations, lay them all out with the evidence for each.' -].join('\n') +const PLAN_PROMPT = `You are the Plan sub-agent. Your job is to analyze code and come up with implementation plans. You can only read, not write. + +Tools you have: +file_read — read file contents or list a directory. Use offset/limit for large files. +grep — search file contents with regex. Use include patterns to narrow things down (e.g. "*.ts"). +glob — find files by pattern (e.g. "src/**/*.ts"). + +You don't have write access. Don't try to use file_write, file_edit, or bash. + +Start by understanding what's being asked, then go read the relevant code, search for related logic, and trace through call chains and constraints. Once you have a clear picture, put together a concrete plan covering which files need to change, what the key code changes look like, any compatibility concerns, and how to test it. + +Be specific — include file paths, function names, and the order things should happen in. If something is unclear or needs a human decision, say so.` + +const GENERAL_PROMPT = `You are the General sub-agent. You handle implementation tasks from start to finish. + +Tools you have: +file_read — read file contents or list a directory. Use offset/limit for large files. +file_write — create or overwrite files. Parent directories are created automatically. +file_edit — make targeted replacements in existing files. Use this over file_write for small changes. +grep — search file contents with regex. +glob — find files by pattern. +bash — run shell commands for building, testing, scripts, git, etc. + +Read the relevant code first to understand context before changing anything. For small edits use file_edit; only use file_write for new files or full rewrites. After making changes, run the build or tests to make sure things still work, then summarize what you changed and why. + +Always read a file before editing it — don't guess at contents. Keep changes focused and don't refactor unrelated code. If the task touches many files, work through them one at a time. Use safe shell commands; avoid rm -rf or force operations unless you're told to.` + +const EXPLORE_PROMPT = `You are the Explore sub-agent. You quickly gather information from the codebase. You can only read, not write. + +Tools you have: +file_read — read file contents or list a directory. Use offset/limit for large files. +grep — search file contents with regex. Use include patterns to narrow things down. +glob — find files by pattern. + +You don't have write access. Don't try to use file_write, file_edit, or bash. + +Start with broad searches using glob and grep to find relevant files, then read them selectively. Follow imports, call sites, and type definitions to piece together how things connect. + +Give back exact file paths, line numbers, and code snippets. Stick to facts and don't speculate. If there are multiple possible interpretations, lay them all out with the evidence for each.` diff --git a/packages/extension-agent/src/sub-agent/catalog.ts b/packages/extension-agent/src/sub-agent/catalog.ts index b6cb25375..676bc19dd 100644 --- a/packages/extension-agent/src/sub-agent/catalog.ts +++ b/packages/extension-agent/src/sub-agent/catalog.ts @@ -15,19 +15,16 @@ export async function buildSubAgentCatalog( manual: Iterable, extra: SubAgentInfo[] = [] ) { - const items = [ - ...[...manual].map((item) => createManualAgent(ctx, item)), - ...getBuiltinAgents(cfg), - ...(await scanMarkdownAgents(ctx, cfg)), - ...extra, - ...getPresetAgents(ctx, cfg) - ].map((item) => ({ - ...item, - permissions: permission.mergePermissions(item.permissions) - })) - - return applyShadowing(items).sort((a, b) => { - if (a.priority !== b.priority) return a.priority - b.priority - return a.name.localeCompare(b.name) - }) + return applyShadowing( + [ + ...[...manual].map((item) => createManualAgent(ctx, item)), + ...getBuiltinAgents(cfg), + ...(await scanMarkdownAgents(ctx, cfg)), + ...extra, + ...getPresetAgents(ctx, cfg) + ].map((item) => ({ + ...item, + permissions: permission.mergePermissions(item.permissions) + })) + ).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) } diff --git a/packages/extension-agent/src/sub-agent/manual.ts b/packages/extension-agent/src/sub-agent/manual.ts index 43ce9e501..4d402bd56 100644 --- a/packages/extension-agent/src/sub-agent/manual.ts +++ b/packages/extension-agent/src/sub-agent/manual.ts @@ -33,83 +33,13 @@ export function createManualAgent( permissions: input.permissions }) - const id = input.id?.trim() || `manual:${randomUUID()}` - - if (item.promptMode === 'preset') { - const preset = item.preset - ? ctx.chatluna.preset.getPreset(item.preset).value - : undefined - - if (!preset) { - return { - id, - name: item.name, - description: item.description, - source: 'manual', - format: item.format, - state: 'missing', - enabled: item.enabled, - chatlunaEnabled: item.chatluna, - characterEnabled: item.character, - characterGroupEnabled: item.characterGroup, - characterPrivateEnabled: item.characterPrivate, - characterGroupMode: item.characterGroupMode, - characterPrivateMode: item.characterPrivateMode, - characterGroupIds: item.characterGroupIds, - characterPrivateIds: item.characterPrivateIds, - authority: item.authority, - hidden: item.hidden ?? false, - priority: input.priority ?? -10, - promptContent: '', - model: item.model, - maxTurns: item.maxTurns, - permissions: item.permissions, - allowKoishiMessageTransform: item.allowKoishiMessageTransform, - diagnostics: ['Referenced preset was not found'], - promptMode: item.promptMode, - preset: item.preset - } - } - - return { - id, - name: item.name, - description: item.description, - source: 'manual', - format: item.format, - state: 'ready', - enabled: item.enabled, - chatlunaEnabled: item.chatluna, - characterEnabled: item.character, - characterGroupEnabled: item.characterGroup, - characterPrivateEnabled: item.characterPrivate, - characterGroupMode: item.characterGroupMode, - characterPrivateMode: item.characterPrivateMode, - characterGroupIds: item.characterGroupIds, - characterPrivateIds: item.characterPrivateIds, - authority: item.authority, - hidden: item.hidden ?? false, - priority: input.priority ?? -10, - promptContent: preset.rawText, - model: item.model, - maxTurns: item.maxTurns, - permissions: item.permissions, - allowKoishiMessageTransform: item.allowKoishiMessageTransform, - diagnostics: [], - promptMode: item.promptMode, - preset: item.preset - } - } - - const promptContent = input.promptContent ?? '' - - return { - id, + const base: SubAgentInfo = { + id: input.id?.trim() || `manual:${randomUUID()}`, name: item.name, description: item.description, source: 'manual', format: item.format, - state: promptContent.trim() ? 'ready' : 'invalid', + state: 'ready', enabled: item.enabled, chatlunaEnabled: item.chatluna, characterEnabled: item.character, @@ -122,13 +52,37 @@ export function createManualAgent( authority: item.authority, hidden: item.hidden ?? false, priority: input.priority ?? -10, - promptContent, + promptContent: '', model: item.model, maxTurns: item.maxTurns, permissions: item.permissions, allowKoishiMessageTransform: item.allowKoishiMessageTransform, - diagnostics: promptContent.trim() ? [] : ['Prompt content is empty'], + diagnostics: [], promptMode: item.promptMode, preset: item.preset } + + if (item.promptMode === 'preset') { + const preset = item.preset + ? ctx.chatluna.preset.getPreset(item.preset).value + : undefined + + if (!preset) { + return { + ...base, + state: 'missing', + diagnostics: ['Referenced preset was not found'] + } + } + + return { ...base, promptContent: preset.rawText } + } + + const content = input.promptContent ?? '' + return { + ...base, + state: content.trim() ? 'ready' : 'invalid', + promptContent: content, + diagnostics: content.trim() ? [] : ['Prompt content is empty'] + } } diff --git a/packages/extension-agent/src/sub-agent/markdown.ts b/packages/extension-agent/src/sub-agent/markdown.ts index 214229d73..be7716432 100644 --- a/packages/extension-agent/src/sub-agent/markdown.ts +++ b/packages/extension-agent/src/sub-agent/markdown.ts @@ -9,7 +9,7 @@ export function createSubAgentMarkdown(input: ManualSubAgentInput) { throw new Error('Sub-agent prompt content is empty') } - const frontmatter = dump( + return `---\n${dump( { name: input.name.trim(), description: input.description?.trim() || input.name.trim(), @@ -31,27 +31,17 @@ export function createSubAgentMarkdown(input: ManualSubAgentInput) { input.allowKoishiMessageTransform ?? false, permissions: input.permissions }, - { - lineWidth: 120, - noRefs: true, - skipInvalid: true - } - ).trimEnd() - - return `---\n${frontmatter}\n---\n\n${prompt}\n` + { lineWidth: 120, noRefs: true, skipInvalid: true } + ).trimEnd()}\n---\n\n${prompt}\n` } export function getSubAgentFileName(name: string) { - const value = name + const result = name .replace(/\.md$/i, '') .trim() // eslint-disable-next-line no-control-regex .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-') .replace(/\s+/g, '-') - - if (!value) { - throw new Error('Sub-agent file name is empty') - } - - return value + if (!result) throw new Error('Sub-agent file name is empty') + return result } diff --git a/packages/extension-agent/src/sub-agent/parse.ts b/packages/extension-agent/src/sub-agent/parse.ts index e919ba31e..194426b75 100644 --- a/packages/extension-agent/src/sub-agent/parse.ts +++ b/packages/extension-agent/src/sub-agent/parse.ts @@ -56,9 +56,40 @@ export function parseAgentFrontmatter( } } - const format = detectAgentFormat(frontmatter, hint) + // Detect format inline + let format: 'chatluna' | 'claude' | 'opencode' + if (hint) { + format = hint + } else if ( + 'disallowedTools' in frontmatter || + 'permissionMode' in frontmatter || + 'maxTurns' in frontmatter + ) { + format = 'claude' + } else if ( + typeof frontmatter.mode === 'string' && + [ + 'primary', + 'subagent', + 'all', + 'agent', + 'ask', + 'allow', + 'deny' + ].includes(frontmatter.mode) + ) { + format = 'opencode' + } else { + format = 'chatluna' + } + const diagnostics: string[] = [] - const permissions = createPermissionConfig() + const permissions: SubAgentPermissionConfig = { + skills: createRule(undefined, 'inherit'), + mcp: createRule(undefined, 'inherit'), + tools: createRule(undefined, 'inherit'), + computer: createRule(undefined, 'deny') + } let promptContent = parsed.body let enabled = true let hidden = false @@ -86,7 +117,38 @@ export function parseAgentFrontmatter( : '' if (format === 'claude') { - applyClaudeFrontmatter(frontmatter, permissions, diagnostics) + const tools = readNames(frontmatter.tools) + const disallowed = readNames(frontmatter.disallowedTools) + const skills = readNames(frontmatter.skills) + const mcpServers = readNames(frontmatter.mcpServers) + + if (tools.length > 0) { + permissions.tools.mode = 'allow' + permissions.tools.allow = tools.filter( + (t) => !disallowed.includes(t) + ) + permissions.tools.deny = disallowed + } else if (disallowed.length > 0) { + permissions.tools.mode = 'deny' + permissions.tools.deny = disallowed + } + + if (skills.length > 0) { + permissions.skills.mode = 'allow' + permissions.skills.allow = skills + } + + if (mcpServers.length > 0) { + permissions.mcp.mode = 'allow' + permissions.mcp.allow = mcpServers + } + + if (frontmatter.permissionMode != null) { + diagnostics.push( + `Claude field 'permissionMode' is not mapped directly: ${String(frontmatter.permissionMode)}` + ) + } + hidden = frontmatter.hidden === true model = typeof frontmatter.model === 'string' @@ -97,7 +159,63 @@ export function parseAgentFrontmatter( ? frontmatter.maxTurns : undefined } else if (format === 'opencode') { - applyOpencodeFrontmatter(frontmatter, permissions, diagnostics) + const tools = readNames(frontmatter.tools) + if (tools.length > 0) { + permissions.tools.mode = 'allow' + permissions.tools.allow = tools + } else if ( + typeof frontmatter.tools === 'object' && + frontmatter.tools != null + ) { + const obj = frontmatter.tools as Record + const allow = Object.entries(obj) + .filter(([, v]) => v === true) + .flatMap(([k]) => mapCompatToolName(k)) + const deny = Object.entries(obj) + .filter(([, v]) => v === false) + .flatMap(([k]) => mapCompatToolName(k)) + + if (allow.length > 0) { + permissions.tools.mode = 'allow' + permissions.tools.allow = Array.from(new Set(allow)) + } else if (deny.length > 0) { + permissions.tools.mode = 'deny' + permissions.tools.deny = Array.from(new Set(deny)) + } + } + + if ( + typeof frontmatter.permission === 'object' && + frontmatter.permission != null + ) { + const perm = frontmatter.permission as Record + applyPermissionMode( + perm.edit, + ['file_write', 'file_edit'], + permissions.tools + ) + applyPermissionMode(perm.bash, ['bash'], permissions.tools) + applyPermissionMode( + perm.webfetch, + [ + 'web_search', + 'browser_open', + 'browser_read_text', + 'browser_get_html', + 'browser_get_links', + 'browser_summarize' + ], + permissions.tools + ) + applyPermissionMode(perm.task, ['task'], permissions.tools) + + if (perm.mcp != null) { + diagnostics.push( + `OpenCode field 'permission.mcp' is not mapped directly` + ) + } + } + hidden = frontmatter.hidden === true enabled = frontmatter.disable === true ? false : enabled model = @@ -162,11 +280,11 @@ export function parseAgentFrontmatter( typeof frontmatter.permissions === 'object' && frontmatter.permissions != null ) { - const item = frontmatter.permissions as Record - permissions.skills = createRule(item.skills, 'inherit') - permissions.mcp = createRule(item.mcp, 'inherit') - permissions.tools = createRule(item.tools, 'inherit') - permissions.computer = createRule(item.computer, 'inherit') + const p = frontmatter.permissions as Record + permissions.skills = createRule(p.skills, 'inherit') + permissions.mcp = createRule(p.mcp, 'inherit') + permissions.tools = createRule(p.tools, 'inherit') + permissions.computer = createRule(p.computer, 'inherit') } } @@ -209,141 +327,6 @@ export function parseAgentFrontmatter( } } -function detectAgentFormat( - frontmatter: Record, - hint?: 'chatluna' | 'claude' | 'opencode' -) { - if (hint) { - return hint - } - - if ( - 'disallowedTools' in frontmatter || - 'permissionMode' in frontmatter || - 'maxTurns' in frontmatter - ) { - return 'claude' - } - - if ( - typeof frontmatter.mode === 'string' && - [ - 'primary', - 'subagent', - 'all', - 'agent', - 'ask', - 'allow', - 'deny' - ].includes(frontmatter.mode) - ) { - return 'opencode' - } - - return 'chatluna' -} - -function applyClaudeFrontmatter( - frontmatter: Record, - permissions: SubAgentPermissionConfig, - diagnostics: string[] -) { - const tools = readNames(frontmatter.tools) - const disallowed = readNames(frontmatter.disallowedTools) - const skillNames = readNames(frontmatter.skills) - const mcpServers = readNames(frontmatter.mcpServers) - - if (tools.length > 0) { - permissions.tools.mode = 'allow' - permissions.tools.allow = tools.filter( - (item) => !disallowed.includes(item) - ) - permissions.tools.deny = disallowed - } else if (disallowed.length > 0) { - permissions.tools.mode = 'deny' - permissions.tools.deny = disallowed - } - - if (skillNames.length > 0) { - permissions.skills.mode = 'allow' - permissions.skills.allow = skillNames - } - - if (mcpServers.length > 0) { - permissions.mcp.mode = 'allow' - permissions.mcp.allow = mcpServers - } - - if (frontmatter.permissionMode != null) { - diagnostics.push( - `Claude field 'permissionMode' is not mapped directly: ${String(frontmatter.permissionMode)}` - ) - } -} - -function applyOpencodeFrontmatter( - frontmatter: Record, - permissions: SubAgentPermissionConfig, - diagnostics: string[] -) { - const tools = readNames(frontmatter.tools) - if (tools.length > 0) { - permissions.tools.mode = 'allow' - permissions.tools.allow = tools - } else if ( - typeof frontmatter.tools === 'object' && - frontmatter.tools != null - ) { - const value = frontmatter.tools as Record - const allow = Object.entries(value) - .filter(([, item]) => item === true) - .flatMap(([key]) => mapCompatToolName(key)) - const deny = Object.entries(value) - .filter(([, item]) => item === false) - .flatMap(([key]) => mapCompatToolName(key)) - - if (allow.length > 0) { - permissions.tools.mode = 'allow' - permissions.tools.allow = Array.from(new Set(allow)) - } else if (deny.length > 0) { - permissions.tools.mode = 'deny' - permissions.tools.deny = Array.from(new Set(deny)) - } - } - - if ( - typeof frontmatter.permission === 'object' && - frontmatter.permission != null - ) { - const item = frontmatter.permission as Record - applyPermissionMode( - item.edit, - ['file_write', 'file_edit'], - permissions.tools - ) - applyPermissionMode(item.bash, ['bash'], permissions.tools) - applyPermissionMode( - item.webfetch, - [ - 'web_search', - 'browser_open', - 'browser_read_text', - 'browser_get_html', - 'browser_get_links', - 'browser_summarize' - ], - permissions.tools - ) - applyPermissionMode(item.task, ['task'], permissions.tools) - - if (item.mcp != null) { - diagnostics.push( - `OpenCode field 'permission.mcp' is not mapped directly` - ) - } - } -} - function applyPermissionMode( value: unknown, names: string[], @@ -366,25 +349,12 @@ function applyPermissionMode( } } -function createPermissionConfig(): SubAgentPermissionConfig { - return { - skills: createRule(undefined, 'inherit'), - mcp: createRule(undefined, 'inherit'), - tools: createRule(undefined, 'inherit'), - computer: createRule(undefined, 'deny') - } -} - function createRule( value: unknown, fallback: PermissionRule['mode'] ): PermissionRule { if (typeof value !== 'object' || value == null) { - return { - mode: fallback, - allow: [], - deny: [] - } + return { mode: fallback, allow: [], deny: [] } } const item = value as Record @@ -405,73 +375,64 @@ function readNames(value: unknown) { if (typeof value === 'string') { return value .split(/\s*,\s*|\s+/) - .map((item) => item.trim()) + .map((s) => s.trim()) .filter(Boolean) - .flatMap((item) => mapCompatToolName(item)) + .flatMap((s) => mapCompatToolName(s)) } - if (!Array.isArray(value)) { - return [] - } + if (!Array.isArray(value)) return [] return value .flatMap((item) => { - if (typeof item === 'string') { - return item - } - + if (typeof item === 'string') return item if (typeof item === 'object' && item != null) { const keys = Object.keys(item as Record) return keys.length > 0 ? keys[0] : [] } - return [] }) - .map((item) => item.trim()) + .map((s) => s.trim()) .filter(Boolean) - .flatMap((item) => mapCompatToolName(item)) + .flatMap((s) => mapCompatToolName(s)) } function readValues(value: unknown) { if (typeof value === 'string') { return value .split(/\s*,\s*|\s+/) - .map((item) => item.trim()) + .map((s) => s.trim()) .filter(Boolean) } - if (!Array.isArray(value)) { - return [] - } + if (!Array.isArray(value)) return [] return value .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) + .map((s) => s.trim()) .filter(Boolean) } +const COMPAT_TOOL_MAP: Record = { + read: ['file_read'], + write: ['file_write'], + edit: ['file_edit'], + bash: ['bash'], + grep: ['grep'], + glob: ['glob'], + webfetch: [ + 'web_search', + 'browser_open', + 'browser_read_text', + 'browser_get_html', + 'browser_get_links', + 'browser_summarize' + ], + task: ['task'], + agent: ['task'] +} + function mapCompatToolName(name: string) { const lower = name.toLowerCase().replace(/\s+/g, '') - - if (lower === 'read') return ['file_read'] - if (lower === 'write') return ['file_write'] - if (lower === 'edit') return ['file_edit'] - if (lower === 'bash') return ['bash'] - if (lower === 'grep') return ['grep'] - if (lower === 'glob') return ['glob'] - if (lower === 'webfetch') { - return [ - 'web_search', - 'browser_open', - 'browser_read_text', - 'browser_get_html', - 'browser_get_links', - 'browser_summarize' - ] - } - if (lower === 'task' || lower === 'agent' || lower.startsWith('agent(')) { - return ['task'] - } - - return [name] + if (lower.startsWith('agent(')) return ['task'] + return COMPAT_TOOL_MAP[lower] ?? [name] } diff --git a/packages/extension-agent/src/sub-agent/preset.ts b/packages/extension-agent/src/sub-agent/preset.ts index 6dd38c605..494e2c50c 100644 --- a/packages/extension-agent/src/sub-agent/preset.ts +++ b/packages/extension-agent/src/sub-agent/preset.ts @@ -45,7 +45,7 @@ export function getPresetAgents( state: 'missing' as const, promptContent: '', diagnostics: ['Referenced preset was not found'] - } satisfies SubAgentInfo + } } return { @@ -53,14 +53,14 @@ export function getPresetAgents( state: 'ready' as const, promptContent: preset.rawText, diagnostics: [] - } satisfies SubAgentInfo + } } catch (err) { return { ...base, state: 'missing' as const, promptContent: '', diagnostics: [err instanceof Error ? err.message : String(err)] - } satisfies SubAgentInfo + } } }) } diff --git a/packages/extension-agent/src/sub-agent/render.ts b/packages/extension-agent/src/sub-agent/render.ts index 2d53410a0..1a27ee1f6 100644 --- a/packages/extension-agent/src/sub-agent/render.ts +++ b/packages/extension-agent/src/sub-agent/render.ts @@ -1,5 +1,3 @@ -/** @module sub-agent/render */ - import { renderAvailableAgents, SubagentContext @@ -20,13 +18,9 @@ export function renderSubAgentSystemPrompt( skills?: string, computer?: { enabled: boolean; backends: string[]; capabilities: string[] } ) { - const lines = [info.promptContent.trim()] - - if (skills) { - lines.push('', skills) - } - - lines.push( + return [ + info.promptContent.trim(), + ...(skills ? ['', skills] : []), '', '', `Sub-agent "${info.name}" | depth: ${context.depth} | parent: ${context.traceInfo.parentAgent} | run: ${context.traceInfo.runId}`, @@ -34,16 +28,15 @@ export function renderSubAgentSystemPrompt( 'If the task exceeds your scope, summarize findings and return to the parent.', 'If shell or computer work may take a while, use managed background execution and inspect it later instead of waiting for the default timeout.', 'When complete, provide a clear summary of results.', - '' - ) - - if (computer?.enabled) { - lines.push( - '', - 'Computer-use capabilities are available for this sub-agent.', - `Available capabilities: ${computer.capabilities.join(', ')}` - ) - } - - return lines.join('\n').trim() + '', + ...(computer?.enabled + ? [ + '', + 'Computer-use capabilities are available for this sub-agent.', + `Available capabilities: ${computer.capabilities.join(', ')}` + ] + : []) + ] + .join('\n') + .trim() } diff --git a/packages/extension-agent/src/sub-agent/run.ts b/packages/extension-agent/src/sub-agent/run.ts index 5f48ed9ce..603c50b79 100644 --- a/packages/extension-agent/src/sub-agent/run.ts +++ b/packages/extension-agent/src/sub-agent/run.ts @@ -6,17 +6,15 @@ import { type AgentToolOptions, applyToolMask, type ChatLunaAgent, - createAgentTool, - type ToolMask + createAgentTool } from 'koishi-plugin-chatluna/llm-core/agent' import { ChatLunaBaseEmbeddings, ChatLunaChatModel } from 'koishi-plugin-chatluna/llm-core/platform/model' -import { ChatLunaTool } from 'koishi-plugin-chatluna/llm-core/platform/types' import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import { getMessageContent } from 'koishi-plugin-chatluna/utils/string' -import { computed, ComputedRef } from 'koishi-plugin-chatluna' +import { computed } from 'koishi-plugin-chatluna' import { Context, h, Session } from 'koishi' import { getRemoteSkillsRoot } from '../computer/materialize' import { getSkillsRootPath } from '../config/path' @@ -85,20 +83,67 @@ async function createInnerAgent( options: CreateSubAgentOptions, input: AgentGenerateOptions ) { - const source = input.source ?? 'chatluna' const toolMask = await options.permission.createSubAgentToolMask( options.info, input.session, - source + input.source ?? 'chatluna' ) - const llm = await resolveModel(options.ctx, options.info, options.model) - const embeddings = await resolveEmbeddings(options.ctx) - const skills = await resolveSkillPrompt( - options.ctx, - options.permission, - options.info, - toolMask + + let llm: ChatLunaChatModel + if (!options.info.model) { + if (!options.model) { + throw new Error('Parent model is missing for sub-agent inheritance') + } + llm = options.model + } else { + const ref = await options.ctx.chatluna.createChatModel( + options.info.model + ) + if (!ref.value) { + throw new Error(`Model not found: ${options.info.model}`) + } + llm = ref.value + } + + const [platform, embModel] = parseRawModelName( + options.ctx.chatluna.config.defaultEmbeddings ) + const embeddings = ( + await options.ctx.chatluna.createEmbeddings(platform, embModel) + ).value as ChatLunaBaseEmbeddings + + const service = options.ctx.chatluna_agent?.skills + const toolCallMask = toolMask.toolCallMask ?? toolMask + let skills: string | undefined + if ( + service && + applyToolMask('skill', toolCallMask) && + options.permission.canUseTool(options.info, 'skill') + ) { + const filtered = options.permission.filterSkills( + options.info, + service.listSkills().filter((item) => item.modelEnabled) + ) + if (filtered.length > 0) { + const cwd = options.ctx.chatluna_agent?.computer.getPromptWorkdir() + const status = options.ctx.chatluna_agent?.computer.getStatus() + const remote = status != null && status.defaultProvider !== 'local' + skills = getMessageContent( + renderAvailableSkills( + filtered.map((item) => + remote ? { ...item, dir: '' } : item + ), + [], + remote + ? getRemoteSkillsRoot() + : getSkillsRootPath(options.ctx), + cwd, + remote ? 'remote' : 'local' + ).content + ) + } + } + const computer = options.ctx.chatluna_agent?.computer const backends = computer ? options.permission.filterComputerBackends( @@ -106,10 +151,25 @@ async function createInnerAgent( computer.listAvailableBackends() ) : [] + const subCtx = input.subagentContext != null ? { ...input.subagentContext, toolMask } - : createFallbackSubagentContext(options.info, input, toolMask) + : { + agentId: options.info.id, + agentName: options.info.name, + parentConversationId: input.conversationId ?? '', + depth: 1, + maxDepth: 1, + toolMask, + disableHandoff: true, + traceInfo: { + runId: options.info.id, + parentAgent: 'main', + startedAt: Date.now() + } + } + const system = renderSubAgentSystemPrompt( options.info, subCtx, @@ -129,6 +189,8 @@ async function createInnerAgent( : undefined ) + const tools = options.ctx.chatluna.platform.getTools() + return { llm, toolMask, @@ -139,7 +201,13 @@ async function createInnerAgent( description: options.info.description, model: llm, embeddings, - tools: createTools(options.ctx, options.permission, options.info), + tools: computed(() => + tools.value + .filter((name) => + options.permission.canUseTool(options.info, name) + ) + .map((name) => options.ctx.chatluna.platform.getTool(name)) + ), system, preset: options.info.promptMode === 'preset' @@ -153,102 +221,6 @@ async function createInnerAgent( } } -function createFallbackSubagentContext( - info: SubAgentInfo, - input: AgentGenerateOptions, - toolMask: ToolMask -) { - return { - agentId: info.id, - agentName: info.name, - parentConversationId: input.conversationId ?? '', - depth: 1, - maxDepth: 1, - toolMask, - disableHandoff: true, - traceInfo: { - runId: info.id, - parentAgent: 'main', - startedAt: Date.now() - } - } -} - -function createTools( - ctx: Context, - permission: ChatLunaAgentPermissionService, - info: SubAgentInfo -): ComputedRef { - const tools = ctx.chatluna.platform.getTools() - - return computed(() => - tools.value - .filter((name) => permission.canUseTool(info, name)) - .map((name) => ctx.chatluna.platform.getTool(name)) - ) -} - -async function resolveModel( - ctx: Context, - info: SubAgentInfo, - parent?: ChatLunaChatModel -) { - if (!info.model) { - if (!parent) { - throw new Error('Parent model is missing for sub-agent inheritance') - } - - return parent - } - - const ref = await ctx.chatluna.createChatModel(info.model) - if (!ref.value) { - throw new Error(`Model not found: ${info.model}`) - } - return ref.value -} - -async function resolveEmbeddings(ctx: Context) { - const [platform, model] = parseRawModelName( - ctx.chatluna.config.defaultEmbeddings - ) - const ref = await ctx.chatluna.createEmbeddings(platform, model) - return ref.value as ChatLunaBaseEmbeddings -} - -async function resolveSkillPrompt( - ctx: Context, - permission: ChatLunaAgentPermissionService, - info: SubAgentInfo, - toolMask: ToolMask -) { - const service = ctx.chatluna_agent?.skills - const toolCallMask = toolMask.toolCallMask ?? toolMask - if (!service) return undefined - if (!applyToolMask('skill', toolCallMask)) return undefined - if (!permission.canUseTool(info, 'skill')) return undefined - - const skills = permission.filterSkills( - info, - service.listSkills().filter((item) => item.modelEnabled) - ) - if (skills.length < 1) return undefined - - const cwd = ctx.chatluna_agent?.computer.getPromptWorkdir() - const status = ctx.chatluna_agent?.computer.getStatus() - const remote = status != null && status.defaultProvider !== 'local' - - return getMessageContent( - renderAvailableSkills( - skills.map((item) => (remote ? { ...item, dir: '' } : item)), - [], - remote ? getRemoteSkillsRoot() : getSkillsRootPath(ctx), - cwd, - remote ? 'remote' : 'local' - ).content - ) -} - async function createPromptMessage( ctx: Context, info: SubAgentInfo, diff --git a/packages/extension-agent/src/sub-agent/runtime.ts b/packages/extension-agent/src/sub-agent/runtime.ts index e94112c02..87bceae83 100644 --- a/packages/extension-agent/src/sub-agent/runtime.ts +++ b/packages/extension-agent/src/sub-agent/runtime.ts @@ -38,9 +38,6 @@ export function isRunnable(info: SubAgentInfo) { } export function clearDisposers(store: Map void>) { - for (const dispose of store.values()) { - dispose() - } - + for (const fn of store.values()) fn() store.clear() } diff --git a/packages/extension-agent/src/sub-agent/scan.ts b/packages/extension-agent/src/sub-agent/scan.ts index 0aea04023..a55dd452c 100644 --- a/packages/extension-agent/src/sub-agent/scan.ts +++ b/packages/extension-agent/src/sub-agent/scan.ts @@ -8,7 +8,7 @@ import { getSubAgentsRootPath } from '../config/path' import { AgentConfig, SubAgentInfo } from '../types' import { collectFilesRecursive } from '../utils/fs' import { createHashId } from '../utils/id' -import { isPathInside, resolveTildeDir, toPathKey } from '../utils/path' +import { expandDir, isPathInside, toPathKey } from '../utils/path' import { parseAgentFrontmatter } from './parse' export { getBuiltinAgents } from './builtin' @@ -43,15 +43,11 @@ export async function scanMarkdownAgents( ctx: Context, cfg: AgentConfig['subAgent'] ) { - const targets = getScanTargets(ctx, cfg) const list = await Promise.all( - targets.map((target) => scanTarget(target, cfg)) + getScanTargets(ctx, cfg).map((t) => scanTarget(t, cfg)) ) return list.flat().sort((a, b) => { - if (a.priority !== b.priority) { - return a.priority - b.priority - } - + if (a.priority !== b.priority) return a.priority - b.priority return (a.path ?? '').localeCompare(b.path ?? '') }) } @@ -60,33 +56,39 @@ function getScanTargets(ctx: Context, cfg: AgentConfig['subAgent']) { const root = getSubAgentsRootPath(ctx) const seen = new Set([toPathKey(root)]) const targets: ScanTarget[] = [ - { - root, - scope: 'data', - priority: 0, - hint: 'chatluna', - remote: false - } + { root, scope: 'data', priority: 0, hint: 'chatluna', remote: false } ] for (let idx = 0; idx < cfg.dirs.length; idx++) { const item = cfg.dirs[idx]?.trim() - if (!item) { - continue - } + if (!item) continue - const dir = resolveTildeDir(ctx.baseDir, item) + const dir = expandDir(ctx.baseDir, item) const key = toPathKey(dir) - if (seen.has(key)) { - continue - } + if (seen.has(key)) continue seen.add(key) + + const combined = `${item}\n${dir}`.replaceAll('\\', '/').toLowerCase() + const hint: ScanTarget['hint'] = combined.includes('/claude/agents') + ? 'claude' + : combined.includes('/opencode/agents') + ? 'opencode' + : 'chatluna' + const scope: ScanTarget['scope'] = isPathInside( + dir, + getSubAgentsRootPath(ctx) + ) + ? 'data' + : isPathInside(dir, ctx.baseDir) + ? 'project' + : 'user' + targets.push({ root: dir, - scope: detectScope(ctx, dir), + scope, priority: 100 + idx, - hint: detectHint(item, dir), + hint, remote: false }) } @@ -96,149 +98,97 @@ function getScanTargets(ctx: Context, cfg: AgentConfig['subAgent']) { async function scanTarget(target: ScanTarget, cfg: AgentConfig['subAgent']) { const info = await stat(target.root).catch(() => undefined) - if (!info?.isDirectory()) { - return [] as SubAgentInfo[] - } - - const files = await collectMarkdownFiles(target.root) - const list = await Promise.all( - files.map((file) => parseMarkdownAgent(file, target, cfg)) - ) - return list as SubAgentInfo[] -} - -async function parseMarkdownAgent( - file: string, - target: ScanTarget, - cfg: AgentConfig['subAgent'] -) { - const raw = await readFile(file, 'utf-8').catch(() => '') - - return parseMarkdownAgentText(file, raw, target, cfg) -} - -function parseMarkdownAgentText( - file: string, - raw: string, - target: ScanTarget, - cfg: AgentConfig['subAgent'] -) { - const id = createAgentId(file) - const parsed = parseAgentFrontmatter( - raw, - basename(file).replace(/\.md$/i, ''), - target.hint - ) - - const base = createSubAgentItemConfig({ - enabled: parsed.value?.enabled ?? true, - name: parsed.value?.name ?? basename(file).replace(/\.md$/i, ''), - description: parsed.value?.description ?? '', - chatluna: parsed.value?.chatluna ?? true, - character: parsed.value?.character ?? true, - characterGroup: parsed.value?.characterGroup ?? true, - characterPrivate: parsed.value?.characterPrivate ?? true, - characterGroupMode: parsed.value?.characterGroupMode, - characterPrivateMode: parsed.value?.characterPrivateMode, - characterGroupIds: parsed.value?.characterGroupIds, - characterPrivateIds: parsed.value?.characterPrivateIds, - authority: parsed.value?.authority, - source: 'markdown', - format: parsed.value?.format ?? target.hint ?? 'chatluna', - model: parsed.value?.model, - maxTurns: parsed.value?.maxTurns, - hidden: parsed.value?.hidden, - promptMode: 'markdown', - allowKoishiMessageTransform: - parsed.value?.allowKoishiMessageTransform ?? false, - permissions: parsed.value?.permissions - }) - - const saved = cfg.items[id] - const item = saved - ? createSubAgentItemConfig({ - ...base, - ...saved, - permissions: { - skills: saved.permissions?.skills ?? base.permissions.skills, - mcp: saved.permissions?.mcp ?? base.permissions.mcp, - tools: saved.permissions?.tools ?? base.permissions.tools, - computer: - saved.permissions?.computer ?? base.permissions.computer - }, - characterGroupIds: - saved.characterGroupIds ?? base.characterGroupIds, - characterPrivateIds: - saved.characterPrivateIds ?? base.characterPrivateIds - }) - : base - - return { - id, - name: item.name, - description: item.description, - source: 'markdown', - format: item.format, - state: parsed.state, - enabled: item.enabled, - chatlunaEnabled: item.chatluna, - characterEnabled: item.character, - characterGroupEnabled: item.characterGroup, - characterPrivateEnabled: item.characterPrivate, - characterGroupMode: item.characterGroupMode, - characterPrivateMode: item.characterPrivateMode, - characterGroupIds: item.characterGroupIds, - characterPrivateIds: item.characterPrivateIds, - authority: item.authority, - hidden: item.hidden ?? false, - remote: target.remote, - path: file, - scope: target.scope, - priority: target.priority, - promptContent: parsed.value?.promptContent ?? parsed.promptContent, - model: item.model, - maxTurns: item.maxTurns, - permissions: item.permissions, - allowKoishiMessageTransform: item.allowKoishiMessageTransform, - diagnostics: parsed.diagnostics, - promptMode: 'markdown' - } satisfies SubAgentInfo -} + if (!info?.isDirectory()) return [] as SubAgentInfo[] -async function collectMarkdownFiles(root: string) { - return await collectFilesRecursive(root, { + const files = await collectFilesRecursive(target.root, { extensionFilter: '.md' }) -} - -function detectScope(ctx: Context, dir: string) { - if (isPathInside(dir, getSubAgentsRootPath(ctx))) { - return 'data' - } - - if (isPathInside(dir, ctx.baseDir)) { - return 'project' - } - - return 'user' -} - -function detectHint(raw: string, dir: string) { - const value = `${raw}\n${dir}`.replaceAll('\\', '/').toLowerCase() - if (value.includes('/.claude/agents') || value.endsWith('/claude/agents')) { - return 'claude' - } - - if ( - value.includes('/.config/opencode/agents') || - value.endsWith('/opencode/agents') - ) { - return 'opencode' - } - - return 'chatluna' -} - -function createAgentId(file: string) { - return createHashId(file) + return await Promise.all( + files.map(async (file) => { + const raw = await readFile(file, 'utf-8').catch(() => '') + const name = basename(file).replace(/\.md$/i, '') + const id = createHashId(file) + const parsed = parseAgentFrontmatter(raw, name, target.hint) + + const base = createSubAgentItemConfig({ + enabled: parsed.value?.enabled ?? true, + name: parsed.value?.name ?? name, + description: parsed.value?.description ?? '', + chatluna: parsed.value?.chatluna ?? true, + character: parsed.value?.character ?? true, + characterGroup: parsed.value?.characterGroup ?? true, + characterPrivate: parsed.value?.characterPrivate ?? true, + characterGroupMode: parsed.value?.characterGroupMode, + characterPrivateMode: parsed.value?.characterPrivateMode, + characterGroupIds: parsed.value?.characterGroupIds, + characterPrivateIds: parsed.value?.characterPrivateIds, + authority: parsed.value?.authority, + source: 'markdown', + format: parsed.value?.format ?? target.hint ?? 'chatluna', + model: parsed.value?.model, + maxTurns: parsed.value?.maxTurns, + hidden: parsed.value?.hidden, + promptMode: 'markdown', + allowKoishiMessageTransform: + parsed.value?.allowKoishiMessageTransform ?? false, + permissions: parsed.value?.permissions + }) + + const saved = cfg.items[id] + const item = saved + ? createSubAgentItemConfig({ + ...base, + ...saved, + permissions: { + skills: + saved.permissions?.skills ?? + base.permissions.skills, + mcp: saved.permissions?.mcp ?? base.permissions.mcp, + tools: + saved.permissions?.tools ?? + base.permissions.tools, + computer: + saved.permissions?.computer ?? + base.permissions.computer + }, + characterGroupIds: + saved.characterGroupIds ?? base.characterGroupIds, + characterPrivateIds: + saved.characterPrivateIds ?? base.characterPrivateIds + }) + : base + + return { + id, + name: item.name, + description: item.description, + source: 'markdown', + format: item.format, + state: parsed.state, + enabled: item.enabled, + chatlunaEnabled: item.chatluna, + characterEnabled: item.character, + characterGroupEnabled: item.characterGroup, + characterPrivateEnabled: item.characterPrivate, + characterGroupMode: item.characterGroupMode, + characterPrivateMode: item.characterPrivateMode, + characterGroupIds: item.characterGroupIds, + characterPrivateIds: item.characterPrivateIds, + authority: item.authority, + hidden: item.hidden ?? false, + remote: target.remote, + path: file, + scope: target.scope, + priority: target.priority, + promptContent: + parsed.value?.promptContent ?? parsed.promptContent, + model: item.model, + maxTurns: item.maxTurns, + permissions: item.permissions, + allowKoishiMessageTransform: item.allowKoishiMessageTransform, + diagnostics: parsed.diagnostics, + promptMode: 'markdown' + } satisfies SubAgentInfo + }) + ) } diff --git a/packages/extension-agent/src/sub-agent/session.ts b/packages/extension-agent/src/sub-agent/session.ts index 3d9c40fe3..1b12d5ee8 100644 --- a/packages/extension-agent/src/sub-agent/session.ts +++ b/packages/extension-agent/src/sub-agent/session.ts @@ -61,29 +61,23 @@ export function appendTaskMessage( message: BaseMessage ) { task.messages.push(message) - touchTaskSession(task) + task.updatedAt = Date.now() } export function appendTaskMessages( task: SubAgentTaskSession, messages: BaseMessage[] ) { - if (messages.length < 1) { - return - } - + if (messages.length < 1) return task.messages.push(...messages) - touchTaskSession(task) + task.updatedAt = Date.now() } export function appendTaskToolBatch( task: SubAgentTaskSession, steps: AgentStep[] ) { - if (steps.length < 1) { - return - } - + if (steps.length < 1) return appendTaskMessages(task, createAgentToolMessages(steps)) } @@ -232,27 +226,21 @@ function createAgentToolMessages(steps: AgentStep[]): BaseMessage[] { function formatTaskHistory(messages: BaseMessage[]) { const lines = messages - .map((message) => { - const text = getMessageContent(message.content) + .map((msg) => { + const text = getMessageContent(msg.content) .replace(/\s+/g, ' ') .trim() - if (!text) { - return undefined - } - - return `${message.getType()}: ${text.length > 280 ? `${text.slice(0, 277)}...` : text}` + if (!text) return undefined + return `${msg.getType()}: ${text.length > 280 ? `${text.slice(0, 277)}...` : text}` }) .filter((item): item is string => item != null) - if (lines.length < 1) { - return '(no messages yet)' - } - + if (lines.length < 1) return '(no messages yet)' return lines.slice(-6).join('\n') } export function isHumanMessages( messages: BaseMessage[] ): messages is HumanMessage[] { - return messages.every((message) => message.getType() === 'human') + return messages.every((msg) => msg.getType() === 'human') } diff --git a/packages/extension-agent/src/sub-agent/tool.ts b/packages/extension-agent/src/sub-agent/tool.ts index a626667a7..af7feda9c 100644 --- a/packages/extension-agent/src/sub-agent/tool.ts +++ b/packages/extension-agent/src/sub-agent/tool.ts @@ -96,17 +96,6 @@ export class TaskTool extends StructuredTool { _: unknown, runConfig?: ChatLunaToolRunnable ) { - return await this.service.runTask( - { - action: input.action, - agent: input.agent, - id: input.id, - prompt: input.prompt, - reason: input.reason, - background: input.background, - message: input.message - }, - runConfig - ) + return await this.service.runTask(input, runConfig) } } diff --git a/packages/extension-agent/src/utils/agentcli_sync.ts b/packages/extension-agent/src/utils/agentcli_sync.ts index 592d4de60..47f6d3c5c 100644 --- a/packages/extension-agent/src/utils/agentcli_sync.ts +++ b/packages/extension-agent/src/utils/agentcli_sync.ts @@ -146,10 +146,8 @@ function sortKeys(value: unknown): unknown { } function isMissingFileError(err: unknown): boolean { - if (!err) return false - const code = (err as NodeJS.ErrnoException).code - if (code === 'ENOENT') return true - const msg = ((err as Error).message ?? '').toLowerCase() + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return true + const msg = (err as Error).message.toLowerCase() return ( msg.includes('no such file') || msg.includes('not found') || diff --git a/packages/extension-agent/src/utils/fs.ts b/packages/extension-agent/src/utils/fs.ts index 4a81b094a..c64f6d445 100644 --- a/packages/extension-agent/src/utils/fs.ts +++ b/packages/extension-agent/src/utils/fs.ts @@ -1,8 +1,3 @@ -/** - * @module utils/fs - * @description 安全路径解析与递归文件收集工具。 - */ - import { readdir } from 'fs/promises' import { join, relative, resolve } from 'path' import { isPathInside } from './path' @@ -30,10 +25,7 @@ export async function collectFilesRecursive( const limit = options.limit ?? Infinity while (queue.length > 0 && result.length < limit) { - const current = queue.shift() - if (!current) { - continue - } + const current = queue.shift()! const entries = await readdir(current, { withFileTypes: true }).catch( () => [] diff --git a/packages/extension-agent/src/utils/path.ts b/packages/extension-agent/src/utils/path.ts index 816b1c9e2..979601158 100644 --- a/packages/extension-agent/src/utils/path.ts +++ b/packages/extension-agent/src/utils/path.ts @@ -1,30 +1,17 @@ -/** - * @module utils/path - * @description 路径标准化与目录归属判断工具。 - */ - import { homedir } from 'os' import { resolve } from 'path' -/** 判断 dir 是否在 root 内。 */ export function isPathInside(dir: string, root: string): boolean { - const target = resolve(dir) - const base = resolve(root) - - return ( - target === base || - target.startsWith(`${base}\\`) || - target.startsWith(`${base}/`) - ) + const a = resolve(dir) + const b = resolve(root) + return a === b || a.startsWith(`${b}\\`) || a.startsWith(`${b}/`) } -/** 路径标准化为小写正斜杠 key,用于去重。 */ export function toPathKey(dir: string): string { return resolve(dir).replaceAll('\\', '/').toLowerCase() } -/** 解析 ~ 前缀和用户目录约定路径。 */ -export function resolveTildeDir(baseDir: string, dir: string): string { +export function expandDir(baseDir: string, dir: string): string { if ( [ '.agents/', @@ -35,18 +22,13 @@ export function resolveTildeDir(baseDir: string, dir: string): string { '.claude\\', '.config/opencode/', '.config\\opencode\\' - ].some((item) => dir.startsWith(item)) + ].some((p) => dir.startsWith(p)) ) { return resolve(homedir(), dir) } - - if (dir === '~') { - return homedir() - } - + if (dir === '~') return homedir() if (dir.startsWith('~/') || dir.startsWith('~\\')) { return resolve(homedir(), dir.slice(2)) } - return resolve(baseDir, dir) } diff --git a/packages/extension-agent/src/utils/remote_path.ts b/packages/extension-agent/src/utils/remote_path.ts index c1c4d414a..c78581c7c 100644 --- a/packages/extension-agent/src/utils/remote_path.ts +++ b/packages/extension-agent/src/utils/remote_path.ts @@ -25,11 +25,7 @@ export function computeRemoteDir(scope: string, dir: string) { } const next = value.replace(/^\.\//, '').replace(/^\//, '') - if (scope === '~') { - return `~/${next}` - } - - if (scope.startsWith('~/')) { + if (scope === '~' || scope.startsWith('~/')) { return `${scope.replace(/\/+$/, '')}/${next}` } diff --git a/packages/extension-agent/src/utils/runtime_sync.ts b/packages/extension-agent/src/utils/runtime_sync.ts index b4d1aec7f..5e8b3e72a 100644 --- a/packages/extension-agent/src/utils/runtime_sync.ts +++ b/packages/extension-agent/src/utils/runtime_sync.ts @@ -20,7 +20,7 @@ import { } from '../config/path' import { REMOTE_SUBAGENTS_ROOT } from '../sub-agent/scan' import type { ChatLunaAgentService } from '../service' -import { resolveTildeDir } from './path' +import { expandDir } from './path' import { quoteShellPath } from './shell' interface RuntimeSyncFile { @@ -203,12 +203,12 @@ async function syncRuntimeSession( [ getSkillsRootPath(agent.ctx), ...DEFAULT_SKILL_DIRS.map((item) => - resolveTildeDir(agent.ctx.baseDir, item) + expandDir(agent.ctx.baseDir, item) ), ...agent.args.config.skills.dirs .map((item) => item.trim()) .filter(Boolean) - .map((item) => resolveTildeDir(agent.ctx.baseDir, item)) + .map((item) => expandDir(agent.ctx.baseDir, item)) ].map((item) => [item.replaceAll('\\', '/').toLowerCase(), item]) ).values() ) @@ -257,8 +257,7 @@ async function collectSyncFiles( } for (const file of remoteFiles) { - const sourcePath = posix.join(remoteRoot, file) - const content = await session.readFile(sourcePath) + const content = await session.readFile(posix.join(remoteRoot, file)) for (const localRoot of localRoots) { const targetPath = join(localRoot, ...file.split('/')) diff --git a/packages/extension-agent/src/utils/shadow.ts b/packages/extension-agent/src/utils/shadow.ts index 1064e95e8..32b25cac2 100644 --- a/packages/extension-agent/src/utils/shadow.ts +++ b/packages/extension-agent/src/utils/shadow.ts @@ -27,28 +27,30 @@ export function applyShadowing< const result: (T & { shadowedBy?: string })[] = [] for (const list of groups.values()) { - const candidates = list.filter((item) => { - if (item.enabled === false) return false - if (item.disabled === true) return false - if (item.hidden === true) return false - if (item.invalid === true) return false - if (item.state != null && item.state !== 'ready') return false - return item.available !== false - }) + const valid = list.filter( + (item) => + item.enabled !== false && + item.disabled !== true && + item.hidden !== true && + item.invalid !== true && + (item.state == null || item.state === 'ready') && + item.available !== false + ) - candidates.sort((a, b) => { + valid.sort((a, b) => { if ((a.remote === true) !== (b.remote === true)) { - if (preferRemote) { - return a.remote === true ? -1 : 1 - } - - return a.remote === true ? 1 : -1 + return a.remote === true + ? preferRemote + ? -1 + : 1 + : preferRemote + ? 1 + : -1 } - return a.priority - b.priority }) - const winner = candidates[0] + const winner = valid[0] for (const item of list) { result.push({ ...item, From 4cb0aa1221e2517da1dea3935245f62c6d37e2ff Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 27 May 2026 16:25:50 +0800 Subject: [PATCH 2/3] [Fix] simplify agent trigger handling --- .../extension-agent/src/commands/agent.ts | 8 +- packages/extension-agent/src/commands/mcp.ts | 4 +- .../src/computer/backends/e2b.ts | 2 +- .../src/computer/backends/open_terminal.ts | 8 +- .../src/computer/background.ts | 2 +- .../extension-agent/src/config/defaults.ts | 40 +++++-- packages/extension-agent/src/config/read.ts | 8 +- packages/extension-agent/src/index.ts | 3 +- packages/extension-agent/src/mcp/content.ts | 107 +++++++----------- packages/extension-agent/src/mcp/storage.ts | 11 +- packages/extension-agent/src/mcp/tool_call.ts | 23 ++-- packages/extension-agent/src/mcp/transport.ts | 16 ++- .../extension-agent/src/service/trigger.ts | 7 +- .../extension-agent/src/sub-agent/parse.ts | 27 +++-- packages/extension-agent/src/sub-agent/run.ts | 4 +- .../extension-agent/src/sub-agent/scan.ts | 2 +- packages/extension-agent/src/trigger/dsl.ts | 14 +-- .../extension-agent/src/trigger/executor.ts | 42 +++---- .../extension-agent/src/trigger/listener.ts | 17 ++- .../src/trigger/provider_registry.ts | 48 ++++---- .../src/trigger/providers/activity.ts | 44 +++---- .../src/trigger/providers/cron.ts | 14 +-- .../extension-agent/src/trigger/render.ts | 10 +- .../extension-agent/src/trigger/scheduler.ts | 15 +-- .../src/trigger/task_registry.ts | 34 ++---- packages/extension-agent/src/trigger/tool.ts | 15 ++- .../src/utils/agentcli_sync.ts | 2 +- packages/extension-agent/src/webui/index.ts | 3 +- 28 files changed, 235 insertions(+), 295 deletions(-) diff --git a/packages/extension-agent/src/commands/agent.ts b/packages/extension-agent/src/commands/agent.ts index 36c2d0540..9fd312a30 100644 --- a/packages/extension-agent/src/commands/agent.ts +++ b/packages/extension-agent/src/commands/agent.ts @@ -22,13 +22,9 @@ export function apply(ctx: Context) { try { const result = await service.syncAgentcliConfig() - const header = result.applied - ? 'agentcli sync: applied' - : 'agentcli sync: no changes' - return `${header}\n${result.message}` + return `${result.applied ? 'agentcli sync: applied' : 'agentcli sync: no changes'}\n${result.message}` } catch (err) { - const msg = getErrorMessage(err) || String(err) || 'unknown error' - return `agentcli sync failed: ${msg}` + return `agentcli sync failed: ${getErrorMessage(err)}` } }) } diff --git a/packages/extension-agent/src/commands/mcp.ts b/packages/extension-agent/src/commands/mcp.ts index 5315dd892..1e466c961 100644 --- a/packages/extension-agent/src/commands/mcp.ts +++ b/packages/extension-agent/src/commands/mcp.ts @@ -124,15 +124,13 @@ export function apply(ctx: Context) { const config = structuredClone( ctx.chatluna_agent.getConsoleData().config ) - let count = 0 for (const [name, server] of Object.entries(servers)) { config.mcp.mcpServers[name] = server as never - count += 1 } await ctx.chatluna_agent.saveMcpConfig(config.mcp) - return `Added ${count} server(s)` + return `Added ${Object.keys(servers).length} server(s)` } }) ) diff --git a/packages/extension-agent/src/computer/backends/e2b.ts b/packages/extension-agent/src/computer/backends/e2b.ts index b225991ad..d8fb750d6 100644 --- a/packages/extension-agent/src/computer/backends/e2b.ts +++ b/packages/extension-agent/src/computer/backends/e2b.ts @@ -502,7 +502,7 @@ export class E2BComputerSession implements ComputerSessionApi { } async getDesktopInfo(): Promise { - return null + return undefined } async screenshot(): Promise { diff --git a/packages/extension-agent/src/computer/backends/open_terminal.ts b/packages/extension-agent/src/computer/backends/open_terminal.ts index acf7dc3ea..a7eaca66e 100644 --- a/packages/extension-agent/src/computer/backends/open_terminal.ts +++ b/packages/extension-agent/src/computer/backends/open_terminal.ts @@ -248,12 +248,8 @@ export class OpenTerminalComputerSession implements ComputerSessionApi { } if (replaceCount === 1) { - if ( - content.indexOf( - oldString, - content.indexOf(oldString) + oldString.length - ) !== -1 - ) { + const firstIdx = content.indexOf(oldString) + if (content.indexOf(oldString, firstIdx + 1) !== -1) { throw new Error( `Found multiple matches for oldString in ${filePath}. ` + 'Provide more surrounding lines in oldString to identify the correct match, or set replaceAll to change every instance.' diff --git a/packages/extension-agent/src/computer/background.ts b/packages/extension-agent/src/computer/background.ts index 56062a29b..a62d9a596 100644 --- a/packages/extension-agent/src/computer/background.ts +++ b/packages/extension-agent/src/computer/background.ts @@ -65,7 +65,7 @@ export function readBackgroundExit( marker: string ) { const lines = (pending + data).split(/\r?\n/) - const rest = lines.pop() + const rest = lines.pop()! for (const line of lines) { if (!line.startsWith(`${marker}:`)) continue diff --git a/packages/extension-agent/src/config/defaults.ts b/packages/extension-agent/src/config/defaults.ts index 4c6fd682c..e86c338b1 100644 --- a/packages/extension-agent/src/config/defaults.ts +++ b/packages/extension-agent/src/config/defaults.ts @@ -44,10 +44,6 @@ function copyRule(rule?: PermissionRule, mode: PermissionRule['mode'] = 'all') { } } -function normalizeMode(value?: string): 'all' | 'allow' | 'deny' { - return value === 'allow' || value === 'deny' ? value : 'all' -} - export function createSubAgentItemConfig( input: Partial = {} ): SubAgentItemConfig { @@ -59,8 +55,16 @@ export function createSubAgentItemConfig( character: input.character !== false, characterGroup: input.characterGroup !== false, characterPrivate: input.characterPrivate !== false, - characterGroupMode: normalizeMode(input.characterGroupMode), - characterPrivateMode: normalizeMode(input.characterPrivateMode), + characterGroupMode: + input.characterGroupMode === 'allow' || + input.characterGroupMode === 'deny' + ? input.characterGroupMode + : 'all', + characterPrivateMode: + input.characterPrivateMode === 'allow' || + input.characterPrivateMode === 'deny' + ? input.characterPrivateMode + : 'all', characterGroupIds: [...(input.characterGroupIds ?? [])], characterPrivateIds: [...(input.characterPrivateIds ?? [])], authority: input.authority ?? 0, @@ -92,8 +96,16 @@ export function createToolItemConfig( character: input.character !== false, characterGroup: input.characterGroup !== false, characterPrivate: input.characterPrivate !== false, - characterGroupMode: normalizeMode(input.characterGroupMode), - characterPrivateMode: normalizeMode(input.characterPrivateMode), + characterGroupMode: + input.characterGroupMode === 'allow' || + input.characterGroupMode === 'deny' + ? input.characterGroupMode + : 'all', + characterPrivateMode: + input.characterPrivateMode === 'allow' || + input.characterPrivateMode === 'deny' + ? input.characterPrivateMode + : 'all', characterGroupIds: [...(input.characterGroupIds ?? [])], characterPrivateIds: [...(input.characterPrivateIds ?? [])], subAgents: copyRule(input.subAgents, 'all'), @@ -117,8 +129,16 @@ export function createSkillItemConfig( character: input.character !== false, characterGroup: input.characterGroup !== false, characterPrivate: input.characterPrivate !== false, - characterGroupMode: normalizeMode(input.characterGroupMode), - characterPrivateMode: normalizeMode(input.characterPrivateMode), + characterGroupMode: + input.characterGroupMode === 'allow' || + input.characterGroupMode === 'deny' + ? input.characterGroupMode + : 'all', + characterPrivateMode: + input.characterPrivateMode === 'allow' || + input.characterPrivateMode === 'deny' + ? input.characterPrivateMode + : 'all', characterGroupIds: [...(input.characterGroupIds ?? [])], characterPrivateIds: [...(input.characterPrivateIds ?? [])], subAgents: copyRule(input.subAgents, 'all') diff --git a/packages/extension-agent/src/config/read.ts b/packages/extension-agent/src/config/read.ts index fe102d1a2..4f563cf12 100644 --- a/packages/extension-agent/src/config/read.ts +++ b/packages/extension-agent/src/config/read.ts @@ -61,10 +61,10 @@ function mergeTool( ...base, registry: mergeToolRegistry(base.registry, cfg?.registry), items: Object.fromEntries( - Object.entries(cfg?.items ?? {}).map(([name, item]) => [ - name, - createToolItemConfig(item, name) - ]) + Object.entries({ + ...(base.items ?? {}), + ...(cfg?.items ?? {}) + }).map(([name, item]) => [name, createToolItemConfig(item, name)]) ) } } diff --git a/packages/extension-agent/src/index.ts b/packages/extension-agent/src/index.ts index 4a93f35f8..2868f961c 100644 --- a/packages/extension-agent/src/index.ts +++ b/packages/extension-agent/src/index.ts @@ -19,9 +19,8 @@ export async function apply(ctx: Context, config: Config) { plugin = new ChatLunaPlugin(ctx, config, 'agent', false) - const agentConfig = await readConfig(ctx) ctx.plugin(ChatLunaAgentService, { - config: agentConfig, + config: await readConfig(ctx), plugin }) diff --git a/packages/extension-agent/src/mcp/content.ts b/packages/extension-agent/src/mcp/content.ts index 15c49e6e5..b4a7c27fa 100644 --- a/packages/extension-agent/src/mcp/content.ts +++ b/packages/extension-agent/src/mcp/content.ts @@ -26,20 +26,6 @@ import { Context } from 'koishi' import { putResourceToChatLunaStorage } from './storage' import { ToolException } from './types' -function isResourceReference( - resource: - | EmbeddedResource['resource'] - | ReadResourceResult['contents'][number] -) { - return ( - typeof resource === 'object' && - resource !== null && - resource.uri != null && - resource['blob'] == null && - resource['text'] == null - ) -} - async function collectResourceBlocks( resource: | EmbeddedResource['resource'] @@ -51,7 +37,11 @@ async function collectResourceBlocks( | (StandardFileBlock & PlainTextContentBlock) )[] > { - if (isResourceReference(resource)) { + if ( + resource.uri != null && + resource['blob'] == null && + resource['text'] == null + ) { const response: ReadResourceResult = await client.readResource({ uri: resource.uri }) @@ -93,19 +83,6 @@ async function collectResourceBlocks( return blocks } -function convertTextBlock( - content: Extract, - useStandardContentBlocks: boolean | undefined -): MessageContentText[] { - return [ - { - type: 'text', - ...(useStandardContentBlocks ? { source_type: 'text' } : {}), - text: content.text - } as MessageContentText - ] -} - async function convertImageBlock( content: Extract, useStandardContentBlocks: boolean | undefined, @@ -147,45 +124,28 @@ async function convertImageBlock( ] } -function convertAudioBlock( - content: Extract -): StandardAudioBlock[] { - return [ - { - type: 'audio', - source_type: 'base64', - data: content.data, - mime_type: content.mimeType - } as StandardAudioBlock - ] -} - async function convertResourceBlock( content: Extract, client: Client, ctx: Context ): Promise<(MessageContentComplex | DataContentBlock)[]> { const blocks = await collectResourceBlocks(content['resource'], client) - const files = await Promise.all( - blocks.map(async (value) => { - const buffer = - value.source_type === 'text' - ? Buffer.from(value.text, 'utf-8') - : value.source_type === 'base64' - ? Buffer.from(value.data, 'base64') - : undefined - - if (buffer == null) { - return undefined - } + const files = ( + await Promise.all( + blocks.map(async (value) => { + const buffer = + value.source_type === 'text' + ? Buffer.from(value.text, 'utf-8') + : Buffer.from(value.data, 'base64') - return await putResourceToChatLunaStorage( - ctx, - buffer, - value.mime_type - ) - }) - ).then((list) => list.filter(Boolean)) + return await putResourceToChatLunaStorage( + ctx, + buffer, + value.mime_type + ) + }) + ) + ).filter(Boolean) if (files.length > 0) { return files.map((file) => ({ @@ -207,7 +167,15 @@ async function toolOutputToContentBlocks( ): Promise<(MessageContentComplex | DataContentBlock)[]> { switch (content.type) { case 'text': - return convertTextBlock(content, useStandardContentBlocks) + return [ + { + type: 'text', + ...(useStandardContentBlocks + ? { source_type: 'text' } + : {}), + text: content.text + } as MessageContentText + ] case 'image': return await convertImageBlock( content, @@ -215,7 +183,14 @@ async function toolOutputToContentBlocks( ctx ) case 'audio': - return convertAudioBlock(content) + return [ + { + type: 'audio', + source_type: 'base64', + data: content.data, + mime_type: content.mimeType + } as StandardAudioBlock + ] case 'resource': return await convertResourceBlock(content, client, ctx) default: @@ -260,7 +235,7 @@ export async function convertCallToolResult( ) } - const convertedContent: (MessageContentComplex | DataContentBlock)[] = ( + const blocks: (MessageContentComplex | DataContentBlock)[] = ( await Promise.all( result.content.map((content) => toolOutputToContentBlocks( @@ -275,9 +250,9 @@ export async function convertCallToolResult( ) ).flat() - if (convertedContent.length === 1 && convertedContent[0].type === 'text') { - return [convertedContent[0].text, []] + if (blocks.length === 1 && blocks[0].type === 'text') { + return [blocks[0].text, []] } - return [convertedContent, []] + return [blocks, []] } diff --git a/packages/extension-agent/src/mcp/storage.ts b/packages/extension-agent/src/mcp/storage.ts index 29836a362..b6d542b7e 100644 --- a/packages/extension-agent/src/mcp/storage.ts +++ b/packages/extension-agent/src/mcp/storage.ts @@ -13,13 +13,14 @@ export async function putResourceToChatLunaStorage( return } - const buffer = typeof blob === 'string' ? Buffer.from(blob, 'base64') : blob - const extension = mimeTypes.extension(mimeType) + const ext = mimeTypes.extension(mimeType) - if (!extension) { + if (!ext) { throw new Error(`Unsupported mime type: ${mimeType}`) } - const fileName = `file.${extension}` - return await ctx.chatluna_storage.createTempFile(buffer, fileName) + return await ctx.chatluna_storage.createTempFile( + typeof blob === 'string' ? Buffer.from(blob, 'base64') : blob, + `file.${ext}` + ) } diff --git a/packages/extension-agent/src/mcp/tool_call.ts b/packages/extension-agent/src/mcp/tool_call.ts index 51b120090..20c6a3baa 100644 --- a/packages/extension-agent/src/mcp/tool_call.ts +++ b/packages/extension-agent/src/mcp/tool_call.ts @@ -33,24 +33,19 @@ export async function callTool( JSON.stringify(args, null, 2) ) - const requestOptions: RequestOptions = { + const opts: RequestOptions = { ...(config?.timeout ? { timeout: config.timeout } : {}), ...(config?.signal ? { signal: config.signal } : {}) } - const callToolArgs: Parameters = [ - { - name: toolName, - arguments: args - } - ] - - if (Object.keys(requestOptions).length > 0) { - callToolArgs.push(undefined) - callToolArgs.push(requestOptions) - } - - const result = await client.callTool(...callToolArgs) + const result = + Object.keys(opts).length > 0 + ? await client.callTool( + { name: toolName, arguments: args }, + undefined, + opts + ) + : await client.callTool({ name: toolName, arguments: args }) return convertCallToolResult( serverName, diff --git a/packages/extension-agent/src/mcp/transport.ts b/packages/extension-agent/src/mcp/transport.ts index a47120624..4290516fe 100644 --- a/packages/extension-agent/src/mcp/transport.ts +++ b/packages/extension-agent/src/mcp/transport.ts @@ -40,21 +40,19 @@ export function createTransport( headers.set(k, v) } - const proxyFetch: FetchLike = (url, init) => { - return plugin.fetch( - url as Parameters[0], - init as Parameters[1], - config.proxy - ) as unknown as ReturnType - } - const transportConfig = { ...config, requestInit: { ...requestInit, headers }, - fetch: proxyFetch + fetch: ((url, init) => { + return plugin.fetch( + url as Parameters[0], + init as Parameters[1], + config.proxy + ) as unknown as ReturnType + }) as FetchLike } if (type === 'sse') { diff --git a/packages/extension-agent/src/service/trigger.ts b/packages/extension-agent/src/service/trigger.ts index eab57e5a6..ce7192900 100644 --- a/packages/extension-agent/src/service/trigger.ts +++ b/packages/extension-agent/src/service/trigger.ts @@ -694,10 +694,9 @@ export class ChatLunaAgentTriggerService { pendingKey: string, item: DeferredWakeup ) { - if (!this._deferred.has(pendingKey)) { - this._deferred.set(pendingKey, new Map()) - } - this._deferred.get(pendingKey).set(key, item) + const map = this._deferred.get(pendingKey) ?? new Map() + map.set(key, item) + this._deferred.set(pendingKey, map) } private async _replayDeferred(platform: string, selfId: string) { diff --git a/packages/extension-agent/src/sub-agent/parse.ts b/packages/extension-agent/src/sub-agent/parse.ts index 194426b75..1fdebe0de 100644 --- a/packages/extension-agent/src/sub-agent/parse.ts +++ b/packages/extension-agent/src/sub-agent/parse.ts @@ -62,8 +62,7 @@ export function parseAgentFrontmatter( format = hint } else if ( 'disallowedTools' in frontmatter || - 'permissionMode' in frontmatter || - 'maxTurns' in frontmatter + 'permissionMode' in frontmatter ) { format = 'claude' } else if ( @@ -117,8 +116,12 @@ export function parseAgentFrontmatter( : '' if (format === 'claude') { - const tools = readNames(frontmatter.tools) - const disallowed = readNames(frontmatter.disallowedTools) + const tools = readNames(frontmatter.tools).flatMap((t) => + mapCompatToolName(t) + ) + const disallowed = readNames(frontmatter.disallowedTools).flatMap((t) => + mapCompatToolName(t) + ) const skills = readNames(frontmatter.skills) const mcpServers = readNames(frontmatter.mcpServers) @@ -159,7 +162,9 @@ export function parseAgentFrontmatter( ? frontmatter.maxTurns : undefined } else if (format === 'opencode') { - const tools = readNames(frontmatter.tools) + const tools = readNames(frontmatter.tools).flatMap((t) => + mapCompatToolName(t) + ) if (tools.length > 0) { permissions.tools.mode = 'allow' permissions.tools.allow = tools @@ -284,6 +289,16 @@ export function parseAgentFrontmatter( permissions.skills = createRule(p.skills, 'inherit') permissions.mcp = createRule(p.mcp, 'inherit') permissions.tools = createRule(p.tools, 'inherit') + permissions.tools.allow = Array.from( + new Set( + permissions.tools.allow.flatMap((t) => mapCompatToolName(t)) + ) + ) + permissions.tools.deny = Array.from( + new Set( + permissions.tools.deny.flatMap((t) => mapCompatToolName(t)) + ) + ) permissions.computer = createRule(p.computer, 'inherit') } } @@ -377,7 +392,6 @@ function readNames(value: unknown) { .split(/\s*,\s*|\s+/) .map((s) => s.trim()) .filter(Boolean) - .flatMap((s) => mapCompatToolName(s)) } if (!Array.isArray(value)) return [] @@ -393,7 +407,6 @@ function readNames(value: unknown) { }) .map((s) => s.trim()) .filter(Boolean) - .flatMap((s) => mapCompatToolName(s)) } function readValues(value: unknown) { diff --git a/packages/extension-agent/src/sub-agent/run.ts b/packages/extension-agent/src/sub-agent/run.ts index 603c50b79..f079b027a 100644 --- a/packages/extension-agent/src/sub-agent/run.ts +++ b/packages/extension-agent/src/sub-agent/run.ts @@ -125,8 +125,8 @@ async function createInnerAgent( service.listSkills().filter((item) => item.modelEnabled) ) if (filtered.length > 0) { - const cwd = options.ctx.chatluna_agent?.computer.getPromptWorkdir() - const status = options.ctx.chatluna_agent?.computer.getStatus() + const cwd = options.ctx.chatluna_agent?.computer?.getPromptWorkdir() + const status = options.ctx.chatluna_agent?.computer?.getStatus() const remote = status != null && status.defaultProvider !== 'local' skills = getMessageContent( renderAvailableSkills( diff --git a/packages/extension-agent/src/sub-agent/scan.ts b/packages/extension-agent/src/sub-agent/scan.ts index a55dd452c..53322d1d3 100644 --- a/packages/extension-agent/src/sub-agent/scan.ts +++ b/packages/extension-agent/src/sub-agent/scan.ts @@ -70,7 +70,7 @@ function getScanTargets(ctx: Context, cfg: AgentConfig['subAgent']) { seen.add(key) const combined = `${item}\n${dir}`.replaceAll('\\', '/').toLowerCase() - const hint: ScanTarget['hint'] = combined.includes('/claude/agents') + const hint: ScanTarget['hint'] = combined.includes('claude/agents') ? 'claude' : combined.includes('/opencode/agents') ? 'opencode' diff --git a/packages/extension-agent/src/trigger/dsl.ts b/packages/extension-agent/src/trigger/dsl.ts index d9a61ae48..25958ed3b 100644 --- a/packages/extension-agent/src/trigger/dsl.ts +++ b/packages/extension-agent/src/trigger/dsl.ts @@ -43,7 +43,6 @@ export function parseDsl(src: string): DslCall { if (typeof verbToken.value !== 'string') { throw new Error(`Expected verb identifier at ${verbToken.pos}`) } - const verb = verbToken.value need('LPAREN') const positional: DslValue[] = [] @@ -58,9 +57,8 @@ export function parseDsl(src: string): DslCall { if (typeof token.value !== 'string') { throw new Error(`Expected named argument at ${token.pos}`) } - const key = token.value take() - named[key] = readValue(take()) + named[token.value] = readValue(take()) } else { if (sawNamed) { throw new Error( @@ -80,7 +78,7 @@ export function parseDsl(src: string): DslCall { throw new Error(`Unexpected token at ${peek().pos}`) } - return { verb, positional, named } + return { verb: verbToken.value as string, positional, named } } export function valueToString(v: DslValue): string { @@ -96,16 +94,12 @@ export function valueToNumber(v: DslValue): number { } export function valueToDurationMs(v: DslValue): number { - if (typeof v === 'object' && 'kind' in v && v.kind === 'duration') { - return v.ms - } + if (typeof v === 'object' && v.kind === 'duration') return v.ms throw new Error('Expected duration') } export function valueToIdent(v: DslValue): string { - if (typeof v === 'object' && 'kind' in v && v.kind === 'ident') { - return v.name - } + if (typeof v === 'object' && v.kind === 'ident') return v.name throw new Error('Expected identifier') } diff --git a/packages/extension-agent/src/trigger/executor.ts b/packages/extension-agent/src/trigger/executor.ts index c1ebda702..301f9cd79 100644 --- a/packages/extension-agent/src/trigger/executor.ts +++ b/packages/extension-agent/src/trigger/executor.ts @@ -13,12 +13,7 @@ import { import type { Message, RenderType } from 'koishi-plugin-chatluna' import { transformMessageContentToElements } from 'koishi-plugin-chatluna/utils/koishi' import { getMessageContent } from 'koishi-plugin-chatluna/utils/string' -import { - parseBindingKey, - type WakeupAction, - type WakeupResult, - type WakeupTarget -} from '../types' +import { parseBindingKey, type WakeupAction, type WakeupResult } from '../types' import { buildVirtualSession } from './session' export class ChatLunaAgentTriggerExecutor { @@ -174,14 +169,13 @@ export class ChatLunaAgentTriggerExecutor { } } } catch (err) { - const message = err instanceof Error ? err.message : String(err) this.ctx.logger.error(err) return { ok: false, requestId, error: { code: 'internal', - message + message: err instanceof Error ? err.message : String(err) }, stats: { durationMs: Date.now() - startedAt, @@ -257,7 +251,13 @@ export class ChatLunaAgentTriggerExecutor { ): Promise< { session: Session; bindingKey: string } | { result: WakeupResult } > { - const target = action.target ?? this._legacyTarget(action) + const target = + action.target ?? + action.session ?? + action.routing ?? + (action.bindingKey != null + ? { bindingKey: action.bindingKey } + : undefined) if (target == null) { return { result: errorResult( @@ -271,8 +271,12 @@ export class ChatLunaAgentTriggerExecutor { let session: Session | undefined let bindingKey: string | undefined - if (isSession(target)) { - session = target + if ( + typeof target === 'object' && + 'bot' in target && + 'platform' in target + ) { + session = target as Session } else if ('bindingKey' in target) { bindingKey = target.bindingKey const parsed = parseBindingKey(bindingKey) @@ -336,13 +340,6 @@ export class ChatLunaAgentTriggerExecutor { return { session, bindingKey } } - private _legacyTarget(action: WakeupAction): WakeupTarget | undefined { - if (action.session != null) return action.session - if (action.routing != null) return action.routing - if (action.bindingKey != null) return { bindingKey: action.bindingKey } - return undefined - } - private async _runChainMode( session: Session, resolved: ConversationResolution & { conversation: ConversationRecord }, @@ -459,15 +456,6 @@ export class ChatLunaAgentTriggerExecutor { } } -function isSession(target: WakeupTarget): target is Session { - return ( - typeof target === 'object' && - target != null && - 'bot' in target && - 'platform' in target - ) -} - function resolveBot( ctx: Context, platform: string, diff --git a/packages/extension-agent/src/trigger/listener.ts b/packages/extension-agent/src/trigger/listener.ts index 8c44b3160..761f75d3c 100644 --- a/packages/extension-agent/src/trigger/listener.ts +++ b/packages/extension-agent/src/trigger/listener.ts @@ -66,18 +66,23 @@ export class ChatLunaAgentTriggerListener { } async handle(session: Session, input?: string) { - const content = input ?? h.select(session.elements, 'text').join('') - const text = content.trim() + const text = ( + input ?? h.select(session.elements, 'text').join('') + ).trim() const now = Date.now() this._compact(now) const key = `${session.uid}:${session.guildId ?? 'd'}:${session.channelId}` let entry = this._bindings.get(key) if (entry == null) { - const bindingKey = ( - await this.ctx.chatluna.conversation.resolveConstraint(session) - ).bindingKey - entry = { key: bindingKey, ts: now } + entry = { + key: ( + await this.ctx.chatluna.conversation.resolveConstraint( + session + ) + ).bindingKey, + ts: now + } } else { entry.ts = now } diff --git a/packages/extension-agent/src/trigger/provider_registry.ts b/packages/extension-agent/src/trigger/provider_registry.ts index 1af696349..01d5a5d14 100644 --- a/packages/extension-agent/src/trigger/provider_registry.ts +++ b/packages/extension-agent/src/trigger/provider_registry.ts @@ -14,37 +14,39 @@ export class ChatLunaAgentTriggerProviderRegistry { } get(kind: string | null | undefined) { - if (kind == null) { - return - } - + if (kind == null) return return this._providers.get(kind) } list() { - return Array.from(this._providers.values()).sort((a, b) => + return [...this._providers.values()].sort((a, b) => a.kind.localeCompare(b.kind) ) } listDescriptors(): TriggerProviderDescriptor[] { - return this.list().map((provider) => ({ - kind: provider.kind, - name: provider.name, - description: provider.description, - passive: provider.passive, - scheduled: provider.scheduled, - needsMessage: provider.needsMessage, - schema: provider.schema - ? getSchema(provider.kind, provider.schema) - : undefined - })) - } -} - -function getSchema(kind: string, schema: TriggerProvider['schema']) { - const json = zodToJsonSchema(schema!, kind) as { - definitions?: Record> + return this.list().map((provider) => { + let schema: Record | undefined + if (provider.schema) { + const json = zodToJsonSchema( + provider.schema, + provider.kind + ) as { + definitions?: Record> + } + schema = + json.definitions?.[provider.kind] ?? + (json as Record) + } + return { + kind: provider.kind, + name: provider.name, + description: provider.description, + passive: provider.passive, + scheduled: provider.scheduled, + needsMessage: provider.needsMessage, + schema + } + }) } - return json.definitions?.[kind] ?? (json as Record) } diff --git a/packages/extension-agent/src/trigger/providers/activity.ts b/packages/extension-agent/src/trigger/providers/activity.ts index 293a368c4..b0ef2b9d4 100644 --- a/packages/extension-agent/src/trigger/providers/activity.ts +++ b/packages/extension-agent/src/trigger/providers/activity.ts @@ -194,12 +194,6 @@ interface ActivityState { const STATE_LIMIT = 512 const states = new Map() -function resolveDirection(params: ActivityParams): Direction { - if (params.direction === 'up') return 'up' - if (params.direction === 'down') return 'down' - return params.initialScore < params.activeThreshold ? 'up' : 'down' -} - function decayTowards( score: number, initial: number, @@ -219,19 +213,14 @@ function curveGain( ): number { if (distinct < minDistinct) return -0.5 const excess = distinct - minDistinct + 1 - let value: number switch (curve) { case 'linear': - value = excess - break + return excess * gain case 'sqrt': - value = Math.sqrt(excess) - break + return Math.sqrt(excess) * gain case 'log': - value = Math.log1p(excess) - break + return Math.log1p(excess) * gain } - return value * gain } function gcStates(now: number) { @@ -241,9 +230,7 @@ function gcStates(now: number) { } } - if (states.size <= STATE_LIMIT) { - return - } + if (states.size <= STATE_LIMIT) return const sorted = [...states.entries()].sort( (a, b) => a[1].lastTouched - b[1].lastTouched @@ -341,19 +328,15 @@ function selectElements(session: Session) { hasMedia = true } } - const text = h - .select(elements as h[], 'text') - .join('') - .trim() - const isEmojiOnly = - text.length > 0 && - /^[\p{Emoji}\p{P}\p{S}\s]+$/u.test(text) && - !/[\p{L}\p{N}]/u.test(text) + const text = h.select(elements, 'text').join('').trim() return { hasMention, hasMedia, hasQuote: session.quote != null, - isEmojiOnly + isEmojiOnly: + text.length > 0 && + /^[\p{Emoji}\p{P}\p{S}\s]+$/u.test(text) && + !/[\p{L}\p{N}]/u.test(text) } } @@ -386,7 +369,14 @@ export const activityTriggerProvider: TriggerProvider = { return null } const params = parsed.data - const direction = resolveDirection(params) + const direction: Direction = + params.direction === 'up' + ? 'up' + : params.direction === 'down' + ? 'down' + : params.initialScore < params.activeThreshold + ? 'up' + : 'down' const now = Date.now() let state = states.get(task.id) diff --git a/packages/extension-agent/src/trigger/providers/cron.ts b/packages/extension-agent/src/trigger/providers/cron.ts index 7782e97f4..68d90db21 100644 --- a/packages/extension-agent/src/trigger/providers/cron.ts +++ b/packages/extension-agent/src/trigger/providers/cron.ts @@ -46,17 +46,17 @@ export const cronTriggerProvider: TriggerProvider = { throw new Error('Cron expression is required') } - const base = Math.max( - currentDate?.valueOf() ?? 0, - firedAt?.valueOf() ?? 0, - Date.now() - ) - return { enabled: true, nextFireAt: new Date( CronExpressionParser.parse(expression, { - currentDate: new Date(base) + currentDate: new Date( + Math.max( + currentDate?.valueOf() ?? 0, + firedAt?.valueOf() ?? 0, + Date.now() + ) + ) }) .next() .getTime() diff --git a/packages/extension-agent/src/trigger/render.ts b/packages/extension-agent/src/trigger/render.ts index bae296fdb..6225f60bc 100644 --- a/packages/extension-agent/src/trigger/render.ts +++ b/packages/extension-agent/src/trigger/render.ts @@ -116,7 +116,9 @@ function renderType(schema: ZodTypeAny, indent: number): string { const pad = ' '.repeat(indent) const closePad = ' '.repeat(indent - 1) const entries = Object.entries(shape).map(([key, value]) => { - const optional = isOptional(value) + const typeName = value._def?.typeName as string | undefined + const optional = + typeName === 'ZodOptional' || typeName === 'ZodDefault' const desc = value.description ? ` // ${value.description.replaceAll('\n', ' ')}` : '' @@ -155,9 +157,3 @@ function renderType(schema: ZodTypeAny, indent: number): string { } return 'unknown' } - -function isOptional(schema: ZodTypeAny): boolean { - const name = schema._def?.typeName as string | undefined - if (name === 'ZodOptional' || name === 'ZodDefault') return true - return false -} diff --git a/packages/extension-agent/src/trigger/scheduler.ts b/packages/extension-agent/src/trigger/scheduler.ts index eb66dfcc2..eddec4345 100644 --- a/packages/extension-agent/src/trigger/scheduler.ts +++ b/packages/extension-agent/src/trigger/scheduler.ts @@ -37,9 +37,7 @@ export class ChatLunaAgentTriggerScheduler { sync(task: TriggerTask) { this.remove(task.id) - if (!task.enabled || task.nextFireAt == null) { - return - } + if (!task.enabled || task.nextFireAt == null) return let active = true let dispose = () => {} @@ -80,9 +78,7 @@ export class ChatLunaAgentTriggerScheduler { } } const schedule = () => { - if (!active) { - return - } + if (!active) return const delay = Math.max(task.nextFireAt.valueOf() - Date.now(), 0) dispose = this.ctx.setTimeout( @@ -100,12 +96,7 @@ export class ChatLunaAgentTriggerScheduler { } remove(id: number) { - const dispose = this._timers.get(id) - if (dispose == null) { - return - } - - dispose() + this._timers.get(id)?.() this._timers.delete(id) } } diff --git a/packages/extension-agent/src/trigger/task_registry.ts b/packages/extension-agent/src/trigger/task_registry.ts index 6661da4f9..9fa6208fe 100644 --- a/packages/extension-agent/src/trigger/task_registry.ts +++ b/packages/extension-agent/src/trigger/task_registry.ts @@ -137,15 +137,10 @@ export class ChatLunaAgentTriggerTaskRegistry { ) { return false } - - if ( - filter?.enabled !== undefined && - task.enabled !== filter.enabled - ) { - return false - } - - return true + return ( + filter?.enabled === undefined || + task.enabled === filter.enabled + ) }) .sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf()) } @@ -158,8 +153,9 @@ export class ChatLunaAgentTriggerTaskRegistry { await this._load() const ids = new Set(this._bindingKeys.get(bindingKey) ?? []) if (includeAllScope) { - const baseKey = getBaseBindingKey(bindingKey) - const baseIds = this._baseBindingKeys.get(baseKey) + const baseIds = this._baseBindingKeys.get( + getBaseBindingKey(bindingKey) + ) if (baseIds != null) { for (const id of baseIds) { const task = this._tasks.get(id) @@ -170,27 +166,17 @@ export class ChatLunaAgentTriggerTaskRegistry { } } - if (ids.size < 1) { - return [] - } + if (ids.size < 1) return [] return [...ids] .map((id) => this._tasks.get(id)) .filter((task): task is TriggerTask => task != null) - .filter((task) => { - if (enabled === undefined) { - return true - } - - return task.enabled === enabled - }) + .filter((task) => enabled === undefined || task.enabled === enabled) .sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf()) } private async _load() { - if (this._loaded) { - return - } + if (this._loaded) return this._ensureDatabase() this._bindingKeys.clear() diff --git a/packages/extension-agent/src/trigger/tool.ts b/packages/extension-agent/src/trigger/tool.ts index 94c9c1010..788e649b2 100644 --- a/packages/extension-agent/src/trigger/tool.ts +++ b/packages/extension-agent/src/trigger/tool.ts @@ -258,13 +258,18 @@ export class TriggerTool extends StructuredTool { ) { continue } - params[ + const paramKey = key === 'missed' ? 'missedRunPolicy' : key === 'fire_at' ? 'fireAt' : key - ] = rawValue(value) + params[paramKey] = + typeof value !== 'object' + ? value + : value.kind === 'duration' + ? value.ms + : value.name } let replyTo: 'channel' | 'user' | 'silent' | undefined @@ -431,9 +436,3 @@ function formatTask(task: TriggerTask) { createdBy: task.createdBy } } - -function rawValue(value: DslValue): unknown { - if (typeof value !== 'object') return value - if (value.kind === 'duration') return value.ms - return value.name -} diff --git a/packages/extension-agent/src/utils/agentcli_sync.ts b/packages/extension-agent/src/utils/agentcli_sync.ts index 47f6d3c5c..4b3e8ebdd 100644 --- a/packages/extension-agent/src/utils/agentcli_sync.ts +++ b/packages/extension-agent/src/utils/agentcli_sync.ts @@ -147,7 +147,7 @@ function sortKeys(value: unknown): unknown { function isMissingFileError(err: unknown): boolean { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return true - const msg = (err as Error).message.toLowerCase() + const msg = String(err instanceof Error ? err.message : err).toLowerCase() return ( msg.includes('no such file') || msg.includes('not found') || diff --git a/packages/extension-agent/src/webui/index.ts b/packages/extension-agent/src/webui/index.ts index 04b1a0aad..bf9465e27 100644 --- a/packages/extension-agent/src/webui/index.ts +++ b/packages/extension-agent/src/webui/index.ts @@ -19,10 +19,9 @@ class ChatLunaAgentConsoleService extends DataService { async get(forced?: boolean) { if (this.ctx.chatluna_agent) { - const base = JSON.parse( + return JSON.parse( JSON.stringify(this.ctx.chatluna_agent.getConsoleData()) ) - return base } return { From 55638dc934c7c0bc80181d67cf87da729c94b364 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 27 May 2026 17:09:10 +0800 Subject: [PATCH 3/3] [Fix] tighten agent skill validation --- packages/core/src/llm-core/platform/model.ts | 6 ++- .../skills/agentcli/bin/agentcli.cjs | 2 + .../extension-agent/src/skills/catalog.ts | 40 ------------------- packages/extension-agent/src/skills/scan.ts | 19 ++++++--- .../extension-agent/src/trigger/executor.ts | 10 ++++- 5 files changed, 27 insertions(+), 50 deletions(-) diff --git a/packages/core/src/llm-core/platform/model.ts b/packages/core/src/llm-core/platform/model.ts index 9ad10c0ef..4f35a22fb 100644 --- a/packages/core/src/llm-core/platform/model.ts +++ b/packages/core/src/llm-core/platform/model.ts @@ -308,7 +308,8 @@ export class ChatLunaChatModel extends BaseChatModel { ) { if (hasChunk) { logger.debug( - 'Stream failed after yielding chunks, cannot retry' + 'Stream failed after yielding chunks, cannot retry', + error ) } if (reportUsage) { @@ -322,7 +323,8 @@ export class ChatLunaChatModel extends BaseChatModel { } logger.debug( - `Stream failed before first chunk (attempt ${attempt + 1}/${maxRetries}), retrying...` + `Stream failed before first chunk (attempt ${attempt + 1}/${maxRetries}), retrying...`, + error ) await sleep(2000 * 2 ** attempt) } diff --git a/packages/extension-agent/resources/skills/agentcli/bin/agentcli.cjs b/packages/extension-agent/resources/skills/agentcli/bin/agentcli.cjs index ab46fa927..92416e12f 100644 --- a/packages/extension-agent/resources/skills/agentcli/bin/agentcli.cjs +++ b/packages/extension-agent/resources/skills/agentcli/bin/agentcli.cjs @@ -538,6 +538,8 @@ function ensureSkillEntry(cfg, key) { for (const [id, item] of Object.entries(cfg.skills.items)) { if (item.name === key) return { id, item } } + const scanned = scanSkillDirs(cfg) + if (!scanned.includes(key)) throw new Error(`Skill not found: ${key}`) cfg.skills.items[key] = { enabled: true, mode: 'description' } return { id: key, item: cfg.skills.items[key] } } diff --git a/packages/extension-agent/src/skills/catalog.ts b/packages/extension-agent/src/skills/catalog.ts index aafa896ec..6bfe123b4 100644 --- a/packages/extension-agent/src/skills/catalog.ts +++ b/packages/extension-agent/src/skills/catalog.ts @@ -75,46 +75,6 @@ export function buildSkillCatalog( }) } - for (const [id, item] of Object.entries(configItems)) { - if (skillMap.has(id) || item.remote) continue - - const cfg = createSkillItemConfig(item) - if (!cfg.enabled && cfg.mode !== 'description' && cfg.mode !== 'full') { - continue - } - - catalog.push({ - id, - name: id, - description: '', - path: '', - dir: '', - remote: false, - source: 'chatluna', - scope: 'data', - state: 'missing', - enabled: cfg.enabled, - mode: cfg.mode, - authority: cfg.authority ?? 0, - main: cfg.main, - chatlunaEnabled: cfg.chatluna, - characterEnabled: cfg.character, - characterGroupEnabled: cfg.characterGroup, - characterPrivateEnabled: cfg.characterPrivate, - characterGroupMode: cfg.characterGroupMode, - characterPrivateMode: cfg.characterPrivateMode, - characterGroupIds: cfg.characterGroupIds, - characterPrivateIds: cfg.characterPrivateIds, - subAgents: cfg.subAgents, - available: false, - visible: false, - modelEnabled: false, - userInvocable: false, - implicitInvocation: false, - diagnostics: ['Configured skill was not found during scan'] - }) - } - return catalog.sort((a, b) => { const ap = skillMap.get(a.id)?.priority ?? 9999 const bp = skillMap.get(b.id)?.priority ?? 9999 diff --git a/packages/extension-agent/src/skills/scan.ts b/packages/extension-agent/src/skills/scan.ts index acbdbd485..ea5382db9 100644 --- a/packages/extension-agent/src/skills/scan.ts +++ b/packages/extension-agent/src/skills/scan.ts @@ -282,16 +282,20 @@ async function parseSkillText(input: { diagnostics.push(...extra.diagnostics) const openclaw = parseOpenClawMetadata(frontmatter.metadata) - const name = - typeof frontmatter.name === 'string' && frontmatter.name - ? frontmatter.name - : basename(input.dir) + const rawName = + typeof frontmatter.name === 'string' ? frontmatter.name.trim() : '' + const name = rawName || basename(input.dir) + const validName = /^[a-z0-9]+(-[a-z0-9]+)*$/.test(rawName) const description = typeof frontmatter.description === 'string' ? frontmatter.description.trim() : '' - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) { + if (!rawName) { + diagnostics.push('Skill name is required') + } + + if (rawName && !validName) { diagnostics.push('Skill name should match ^[a-z0-9]+(-[a-z0-9]+)*$') } @@ -324,7 +328,10 @@ async function parseSkillText(input: { source: input.target.source, scope: input.target.scope, remote: input.target.remote, - state: description ? 'ready' : 'invalid', + state: + rawName && validName && name.length <= 64 && description + ? 'ready' + : 'invalid', enabled: mode !== 'off', available: availability.available, userInvocable: frontmatter['user-invocable'] !== false, diff --git a/packages/extension-agent/src/trigger/executor.ts b/packages/extension-agent/src/trigger/executor.ts index 301f9cd79..8f2650d2a 100644 --- a/packages/extension-agent/src/trigger/executor.ts +++ b/packages/extension-agent/src/trigger/executor.ts @@ -13,7 +13,12 @@ import { import type { Message, RenderType } from 'koishi-plugin-chatluna' import { transformMessageContentToElements } from 'koishi-plugin-chatluna/utils/koishi' import { getMessageContent } from 'koishi-plugin-chatluna/utils/string' -import { parseBindingKey, type WakeupAction, type WakeupResult } from '../types' +import { + parseBindingKey, + type WakeupAction, + type WakeupResult, + type WakeupRouting +} from '../types' import { buildVirtualSession } from './session' export class ChatLunaAgentTriggerExecutor { @@ -273,6 +278,7 @@ export class ChatLunaAgentTriggerExecutor { if ( typeof target === 'object' && + target != null && 'bot' in target && 'platform' in target ) { @@ -311,7 +317,7 @@ export class ChatLunaAgentTriggerExecutor { requestId ) if ('result' in routed) return { result: routed.result } - session = buildVirtualSession(routed.bot, target, { + session = buildVirtualSession(routed.bot, target as WakeupRouting, { message: action.message, messageName: action.messageName, requestId