From 200b2cefdaa8f5ae7bd3e7b9ddff66022c8fd64c Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Fri, 30 Jan 2026 02:47:30 +0800 Subject: [PATCH] feat(agent): add profile system and improve tools - Add Agent Profile module for managing agent identity, soul, tools, memory, and bootstrap configuration - Add profile CLI (pnpm agent:profile) for creating/listing/showing profiles - Default sessionId to UUIDv7 instead of "default" - Expose Agent.sessionId as public readonly property - Improve exec/process tools error handling (no more crashes on spawn errors) - Add 'output' action to process tool for reading stdout/stderr - Better tool descriptions to guide agent in choosing exec vs process Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + src/agent/cli.ts | 37 +++-- src/agent/index.ts | 1 + src/agent/profile-cli.ts | 205 +++++++++++++++++++++++++++ src/agent/profile/index.ts | 155 ++++++++++++++++++++ src/agent/profile/storage.ts | 94 ++++++++++++ src/agent/profile/templates.ts | 40 ++++++ src/agent/profile/types.ts | 44 ++++++ src/agent/runner.ts | 26 +++- src/agent/session/session-manager.ts | 2 +- src/agent/session/storage.ts | 2 +- src/agent/tools.ts | 12 +- src/agent/tools/exec.ts | 21 ++- src/agent/tools/process.ts | 132 +++++++++++++---- src/agent/types.ts | 26 ++-- 15 files changed, 740 insertions(+), 58 deletions(-) create mode 100644 src/agent/profile-cli.ts create mode 100644 src/agent/profile/index.ts create mode 100644 src/agent/profile/storage.ts create mode 100644 src/agent/profile/templates.ts create mode 100644 src/agent/profile/types.ts diff --git a/package.json b/package.json index 4b5d2bf0..199d5c80 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "tsx src/index.ts", "agent:cli": "tsx src/agent/cli.ts", + "agent:profile": "tsx src/agent/profile-cli.ts", "dev:gateway": "tsx --watch src/gateway/main.ts", "dev:console": "tsx --watch src/console/main.ts", "dev:web": "pnpm --filter @multica/web dev", diff --git a/src/agent/cli.ts b/src/agent/cli.ts index f7a3d343..07c4e69a 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli.ts @@ -2,18 +2,29 @@ import { Agent } from "./runner.js"; type CliOptions = { - provider?: string; - model?: string; - system?: string; - thinking?: string; - cwd?: string; - session?: string; - help?: boolean; + profile?: string | undefined; + provider?: string | undefined; + model?: string | undefined; + system?: string | undefined; + thinking?: string | undefined; + cwd?: string | undefined; + session?: string | undefined; + help?: boolean | undefined; }; function printUsage() { - console.log("Usage: pnpm agent:cli [--provider PROVIDER] [--model MODEL] [--system TEXT] [--thinking LEVEL] [--cwd DIR] [--session ID] "); + console.log("Usage: pnpm agent:cli [options] "); console.log(" echo \"your prompt\" | pnpm agent:cli"); + console.log(""); + console.log("Options:"); + console.log(" --profile ID Load agent profile (identity, soul, tools, memory)"); + console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)"); + console.log(" --model NAME Model name"); + console.log(" --system TEXT System prompt (ignored if --profile is set)"); + console.log(" --thinking LEVEL Thinking level"); + console.log(" --cwd DIR Working directory for commands"); + console.log(" --session ID Session ID for conversation persistence"); + console.log(" --help, -h Show this help"); } function parseArgs(argv: string[]) { @@ -28,6 +39,10 @@ function parseArgs(argv: string[]) { opts.help = true; break; } + if (arg === "--profile") { + opts.profile = args.shift(); + continue; + } if (arg === "--provider") { opts.provider = args.shift(); continue; @@ -88,6 +103,7 @@ async function main() { } const agent = new Agent({ + profileId: opts.profile, provider: opts.provider, model: opts.model, systemPrompt: opts.system, @@ -96,6 +112,11 @@ async function main() { sessionId: opts.session, }); + // 如果是新创建的 session,提示用户 sessionId + if (!opts.session) { + console.error(`[session: ${agent.sessionId}]`); + } + const result = await agent.run(finalPrompt); if (result.error) { console.error(`Error: ${result.error}`); diff --git a/src/agent/index.ts b/src/agent/index.ts index d4edff1e..4f53d09f 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -1,2 +1,3 @@ export * from "./runner.js"; export * from "./types.js"; +export * from "./profile/index.js"; diff --git a/src/agent/profile-cli.ts b/src/agent/profile-cli.ts new file mode 100644 index 00000000..e708e2ce --- /dev/null +++ b/src/agent/profile-cli.ts @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/** + * Agent Profile CLI + * + * Commands: + * new Create a new profile with default templates + * list List all profiles + * show Show profile contents + * edit Open profile directory + */ + +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + createAgentProfile, + loadAgentProfile, + getProfileDir, + profileExists, +} from "./profile/index.js"; + +const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); + +type Command = "new" | "list" | "show" | "edit" | "help"; + +function printUsage() { + console.log("Usage: pnpm profile [options]"); + console.log(""); + console.log("Commands:"); + console.log(" new Create a new profile with default templates"); + console.log(" list List all profiles"); + console.log(" show Show profile contents"); + console.log(" edit Open profile directory in Finder/file manager"); + console.log(" help Show this help"); + console.log(""); + console.log("Examples:"); + console.log(" pnpm profile new my-agent"); + console.log(" pnpm profile list"); + console.log(" pnpm profile show my-agent"); +} + +function cmdNew(profileId: string | undefined) { + if (!profileId) { + console.error("Error: Profile ID is required"); + console.error("Usage: pnpm profile new "); + process.exit(1); + } + + // Validate profile ID + if (!/^[a-zA-Z0-9_-]+$/.test(profileId)) { + console.error("Error: Profile ID can only contain letters, numbers, hyphens, and underscores"); + process.exit(1); + } + + if (profileExists(profileId)) { + console.error(`Error: Profile "${profileId}" already exists`); + console.error(`Location: ${getProfileDir(profileId)}`); + process.exit(1); + } + + const profile = createAgentProfile(profileId); + const dir = getProfileDir(profileId); + + console.log(`Created profile: ${profile.id}`); + console.log(`Location: ${dir}`); + console.log(""); + console.log("Files created:"); + console.log(" - soul.md (personality and constraints)"); + console.log(" - identity.md (name and role)"); + console.log(" - tools.md (tool usage instructions)"); + console.log(" - memory.md (persistent knowledge)"); + console.log(" - bootstrap.md (initial context)"); + console.log(""); + console.log("Edit these files to customize your agent, then run:"); + console.log(` pnpm agent:cli --profile ${profileId} "Hello"`); +} + +function cmdList() { + if (!existsSync(DEFAULT_BASE_DIR)) { + console.log("No profiles found."); + console.log(`Create one with: pnpm profile new `); + return; + } + + const entries = readdirSync(DEFAULT_BASE_DIR, { withFileTypes: true }); + const profiles = entries.filter((e) => e.isDirectory()).map((e) => e.name); + + if (profiles.length === 0) { + console.log("No profiles found."); + console.log(`Create one with: pnpm profile new `); + return; + } + + console.log("Available profiles:"); + console.log(""); + for (const id of profiles) { + const dir = getProfileDir(id); + console.log(` ${id}`); + console.log(` ${dir}`); + } + console.log(""); + console.log(`Total: ${profiles.length} profile(s)`); +} + +function cmdShow(profileId: string | undefined) { + if (!profileId) { + console.error("Error: Profile ID is required"); + console.error("Usage: pnpm profile show "); + process.exit(1); + } + + const profile = loadAgentProfile(profileId); + if (!profile) { + console.error(`Error: Profile "${profileId}" not found`); + console.error(`Create it with: pnpm profile new ${profileId}`); + process.exit(1); + } + + console.log(`Profile: ${profile.id}`); + console.log(`Location: ${getProfileDir(profileId)}`); + console.log(""); + + if (profile.identity) { + console.log("=== identity.md ==="); + console.log(profile.identity.trim()); + console.log(""); + } + + if (profile.soul) { + console.log("=== soul.md ==="); + console.log(profile.soul.trim()); + console.log(""); + } + + if (profile.tools) { + console.log("=== tools.md ==="); + console.log(profile.tools.trim()); + console.log(""); + } + + if (profile.memory) { + console.log("=== memory.md ==="); + console.log(profile.memory.trim()); + console.log(""); + } + + if (profile.bootstrap) { + console.log("=== bootstrap.md ==="); + console.log(profile.bootstrap.trim()); + console.log(""); + } +} + +async function cmdEdit(profileId: string | undefined) { + if (!profileId) { + console.error("Error: Profile ID is required"); + console.error("Usage: pnpm profile edit "); + process.exit(1); + } + + if (!profileExists(profileId)) { + console.error(`Error: Profile "${profileId}" not found`); + console.error(`Create it with: pnpm profile new ${profileId}`); + process.exit(1); + } + + const dir = getProfileDir(profileId); + const { spawn } = await import("node:child_process"); + + // Open in default file manager + const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open"; + spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref(); + + console.log(`Opened: ${dir}`); +} + +async function main() { + const args = process.argv.slice(2); + const command = (args[0] || "help") as Command; + const arg1 = args[1]; + + switch (command) { + case "new": + cmdNew(arg1); + break; + case "list": + cmdList(); + break; + case "show": + cmdShow(arg1); + break; + case "edit": + await cmdEdit(arg1); + break; + case "help": + default: + printUsage(); + break; + } +} + +main().catch((err) => { + console.error(err?.stack || String(err)); + process.exit(1); +}); diff --git a/src/agent/profile/index.ts b/src/agent/profile/index.ts new file mode 100644 index 00000000..5f0fc87a --- /dev/null +++ b/src/agent/profile/index.ts @@ -0,0 +1,155 @@ +/** + * Agent Profile 模块 + * + * 管理 agent 的身份、人格、记忆等配置 + */ + +import type { AgentProfile, CreateProfileOptions, ProfileManagerOptions } from "./types.js"; +import { DEFAULT_TEMPLATES } from "./templates.js"; +import { + ensureProfileDir, + getProfileDir, + loadProfile, + profileExists, + saveProfile, +} from "./storage.js"; + +export { type AgentProfile, type CreateProfileOptions, type ProfileManagerOptions } from "./types.js"; +export { DEFAULT_TEMPLATES } from "./templates.js"; +export { getProfileDir, profileExists } from "./storage.js"; + +/** + * 创建新的 Agent Profile + * + * @param profileId - Profile ID + * @param options - 创建选项 + * @returns 创建的 AgentProfile + */ +export function createAgentProfile( + profileId: string, + options?: CreateProfileOptions, +): AgentProfile { + const { baseDir, useTemplates = true } = options ?? {}; + + // 确保目录存在 + ensureProfileDir(profileId, { baseDir }); + + // 创建 profile + const profile: AgentProfile = { + id: profileId, + }; + + // 如果使用模板,填充默认内容 + if (useTemplates) { + profile.soul = DEFAULT_TEMPLATES.soul; + profile.identity = DEFAULT_TEMPLATES.identity; + profile.tools = DEFAULT_TEMPLATES.tools; + profile.memory = DEFAULT_TEMPLATES.memory; + profile.bootstrap = DEFAULT_TEMPLATES.bootstrap; + + // 保存到文件 + saveProfile(profile, { baseDir }); + } + + return profile; +} + +/** + * 加载 Agent Profile + * + * @param profileId - Profile ID + * @param options - 加载选项 + * @returns AgentProfile,如果不存在返回 undefined + */ +export function loadAgentProfile( + profileId: string, + options?: { baseDir?: string | undefined }, +): AgentProfile | undefined { + if (!profileExists(profileId, options)) { + return undefined; + } + return loadProfile(profileId, options); +} + +/** + * 加载或创建 Agent Profile + * + * @param profileId - Profile ID + * @param options - 选项 + * @returns AgentProfile + */ +export function getOrCreateAgentProfile( + profileId: string, + options?: CreateProfileOptions, +): AgentProfile { + const existing = loadAgentProfile(profileId, options); + if (existing) { + return existing; + } + return createAgentProfile(profileId, options); +} + +/** + * Profile Manager - 用于管理单个 profile 的类 + */ +export class ProfileManager { + private readonly profileId: string; + private readonly baseDir: string | undefined; + private profile: AgentProfile | undefined; + + constructor(options: ProfileManagerOptions) { + this.profileId = options.profileId; + this.baseDir = options.baseDir; + } + + /** 获取 profile,如果未加载则加载 */ + getProfile(): AgentProfile | undefined { + if (!this.profile) { + this.profile = loadAgentProfile(this.profileId, { baseDir: this.baseDir }); + } + return this.profile; + } + + /** 获取或创建 profile */ + getOrCreateProfile(useTemplates = true): AgentProfile { + if (!this.profile) { + this.profile = getOrCreateAgentProfile(this.profileId, { + baseDir: this.baseDir, + useTemplates, + }); + } + return this.profile; + } + + /** 构建 system prompt */ + buildSystemPrompt(): string { + const profile = this.getProfile(); + if (!profile) { + return ""; + } + + const parts: string[] = []; + + if (profile.identity) { + parts.push(profile.identity); + } + + if (profile.soul) { + parts.push(profile.soul); + } + + if (profile.tools) { + parts.push(profile.tools); + } + + if (profile.memory) { + parts.push(profile.memory); + } + + if (profile.bootstrap) { + parts.push(profile.bootstrap); + } + + return parts.join("\n\n"); + } +} diff --git a/src/agent/profile/storage.ts b/src/agent/profile/storage.ts new file mode 100644 index 00000000..fc281bf3 --- /dev/null +++ b/src/agent/profile/storage.ts @@ -0,0 +1,94 @@ +/** + * Agent Profile 文件存储 + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { PROFILE_FILES, type AgentProfile } from "./types.js"; + +const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles"); + +export interface StorageOptions { + baseDir?: string | undefined; +} + +/** 获取 profile 目录路径 */ +export function getProfileDir(profileId: string, options?: StorageOptions): string { + const baseDir = options?.baseDir ?? DEFAULT_BASE_DIR; + return join(baseDir, profileId); +} + +/** 确保 profile 目录存在 */ +export function ensureProfileDir(profileId: string, options?: StorageOptions): string { + const dir = getProfileDir(profileId, options); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** 检查 profile 是否存在 */ +export function profileExists(profileId: string, options?: StorageOptions): boolean { + const dir = getProfileDir(profileId, options); + return existsSync(dir); +} + +/** 读取单个 profile 文件 */ +export function readProfileFile( + profileId: string, + fileName: string, + options?: StorageOptions, +): string | undefined { + const dir = getProfileDir(profileId, options); + const filePath = join(dir, fileName); + if (!existsSync(filePath)) { + return undefined; + } + return readFileSync(filePath, "utf-8"); +} + +/** 写入单个 profile 文件 */ +export function writeProfileFile( + profileId: string, + fileName: string, + content: string, + options?: StorageOptions, +): void { + const dir = ensureProfileDir(profileId, options); + const filePath = join(dir, fileName); + writeFileSync(filePath, content, "utf-8"); +} + +/** 加载完整的 AgentProfile */ +export function loadProfile(profileId: string, options?: StorageOptions): AgentProfile { + return { + id: profileId, + soul: readProfileFile(profileId, PROFILE_FILES.soul, options), + identity: readProfileFile(profileId, PROFILE_FILES.identity, options), + tools: readProfileFile(profileId, PROFILE_FILES.tools, options), + memory: readProfileFile(profileId, PROFILE_FILES.memory, options), + bootstrap: readProfileFile(profileId, PROFILE_FILES.bootstrap, options), + }; +} + +/** 保存 AgentProfile(只写入非空字段) */ +export function saveProfile(profile: AgentProfile, options?: StorageOptions): void { + const { id, soul, identity, tools, memory, bootstrap } = profile; + + if (soul !== undefined) { + writeProfileFile(id, PROFILE_FILES.soul, soul, options); + } + if (identity !== undefined) { + writeProfileFile(id, PROFILE_FILES.identity, identity, options); + } + if (tools !== undefined) { + writeProfileFile(id, PROFILE_FILES.tools, tools, options); + } + if (memory !== undefined) { + writeProfileFile(id, PROFILE_FILES.memory, memory, options); + } + if (bootstrap !== undefined) { + writeProfileFile(id, PROFILE_FILES.bootstrap, bootstrap, options); + } +} diff --git a/src/agent/profile/templates.ts b/src/agent/profile/templates.ts new file mode 100644 index 00000000..b317e8cc --- /dev/null +++ b/src/agent/profile/templates.ts @@ -0,0 +1,40 @@ +/** + * Agent Profile 默认模板 + */ + +export const DEFAULT_TEMPLATES = { + soul: `# Soul + +You are a helpful AI assistant. Follow these guidelines: + +- Be concise and direct in your responses +- Ask clarifying questions when requirements are ambiguous +- Admit when you don't know something +- Focus on solving the user's actual problem +`, + + identity: `# Identity + +- Name: Assistant +- Role: General-purpose AI assistant +`, + + tools: `# Tools + +Use the available tools effectively: + +- **exec**: Run shell commands. Always check the working directory first. +- **read/write/edit**: File operations. Prefer edit over write for existing files. +- **process**: Manage long-running background processes. +`, + + memory: `# Memory + +(Persistent knowledge will be stored here) +`, + + bootstrap: `# Bootstrap + +You are starting a new conversation. Review the context and be ready to assist. +`, +} as const; diff --git a/src/agent/profile/types.ts b/src/agent/profile/types.ts new file mode 100644 index 00000000..fbbc4f82 --- /dev/null +++ b/src/agent/profile/types.ts @@ -0,0 +1,44 @@ +/** + * Agent Profile 类型定义 + */ + +/** Profile 文件名常量 */ +export const PROFILE_FILES = { + soul: "soul.md", + identity: "identity.md", + tools: "tools.md", + memory: "memory.md", + bootstrap: "bootstrap.md", +} as const; + +/** Agent Profile 配置 */ +export interface AgentProfile { + /** Profile ID */ + id: string; + /** 人格约束 - 定义 agent 的行为边界和风格 */ + soul?: string | undefined; + /** 身份信息 - agent 的名称和自我认知 */ + identity?: string | undefined; + /** 自定义工具描述 - 额外的工具使用说明 */ + tools?: string | undefined; + /** 持久记忆 - 长期知识库 */ + memory?: string | undefined; + /** 初始上下文 - 每次对话的引导信息 */ + bootstrap?: string | undefined; +} + +/** Profile Manager 选项 */ +export interface ProfileManagerOptions { + /** Profile ID */ + profileId: string; + /** 基础目录,默认 ~/.super-multica/agent-profiles */ + baseDir?: string | undefined; +} + +/** 创建 Profile 的选项 */ +export interface CreateProfileOptions { + /** 基础目录 */ + baseDir?: string | undefined; + /** 是否使用默认模板初始化 */ + useTemplates?: boolean | undefined; +} diff --git a/src/agent/runner.ts b/src/agent/runner.ts index e6cd56be..4efcb1b7 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -1,13 +1,19 @@ import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core"; +import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult } from "./types.js"; import { createAgentOutput } from "./output.js"; import { resolveModel, resolveTools } from "./tools.js"; import { SessionManager } from "./session/session-manager.js"; +import { ProfileManager } from "./profile/index.js"; export class Agent { private readonly agent: PiAgentCore; private readonly output; private readonly session: SessionManager; + private readonly profile?: ProfileManager; + + /** 当前会话 ID */ + readonly sessionId: string; constructor(options: AgentOptions = {}) { const stdout = options.logger?.stdout ?? process.stdout; @@ -15,10 +21,24 @@ export class Agent { this.output = createAgentOutput({ stdout, stderr }); this.agent = new PiAgentCore(); - if (options.systemPrompt) this.agent.setSystemPrompt(options.systemPrompt); - const sessionId = options.sessionId ?? "default"; - this.session = new SessionManager({ sessionId }); + // 加载 Agent Profile(如果指定了 profileId) + if (options.profileId) { + this.profile = new ProfileManager({ + profileId: options.profileId, + baseDir: options.profileBaseDir, + }); + const systemPrompt = this.profile.buildSystemPrompt(); + if (systemPrompt) { + this.agent.setSystemPrompt(systemPrompt); + } + } else if (options.systemPrompt) { + // 直接使用传入的 systemPrompt + this.agent.setSystemPrompt(options.systemPrompt); + } + + this.sessionId = options.sessionId ?? uuidv7(); + this.session = new SessionManager({ sessionId: this.sessionId }); const storedMeta = this.session.getMeta(); if (!options.thinkingLevel && storedMeta?.thinkingLevel) { this.agent.setThinkingLevel(storedMeta.thinkingLevel as any); diff --git a/src/agent/session/session-manager.ts b/src/agent/session/session-manager.ts index c62e52e3..b7c6d8bb 100644 --- a/src/agent/session/session-manager.ts +++ b/src/agent/session/session-manager.ts @@ -12,7 +12,7 @@ export type SessionManagerOptions = { export class SessionManager { private readonly sessionId: string; - private readonly baseDir?: string; + private readonly baseDir: string | undefined; private readonly maxMessages: number; private readonly keepLast: number; private queue: Promise = Promise.resolve(); diff --git a/src/agent/session/storage.ts b/src/agent/session/storage.ts index b4629acb..fbffa76a 100644 --- a/src/agent/session/storage.ts +++ b/src/agent/session/storage.ts @@ -5,7 +5,7 @@ import { appendFile, writeFile } from "fs/promises"; import type { SessionEntry } from "./types.js"; export type SessionStorageOptions = { - baseDir?: string; + baseDir?: string | undefined; }; export function resolveBaseDir(options?: SessionStorageOptions) { diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 45aeeb26..3bfcc72e 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,5 +1,5 @@ import type { AgentOptions } from "./types.js"; -import { getModel } from "@mariozechner/pi-ai"; +import { getModel, type KnownProvider } from "@mariozechner/pi-ai"; import { createCodingTools } from "@mariozechner/pi-coding-agent"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { createExecTool } from "./tools/exec.js"; @@ -7,14 +7,18 @@ import { createProcessTool } from "./tools/process.js"; export function resolveModel(options: AgentOptions) { if (options.provider && options.model) { - return getModel(options.provider, options.model); + // Type assertion needed because provider/model come from dynamic user config + return (getModel as (p: string, m: string) => ReturnType)( + options.provider, + options.model, + ); } return getModel("kimi-coding", "kimi-k2-thinking"); } -export function resolveTools(options: AgentOptions) { +export function resolveTools(options: AgentOptions): AgentTool[] { const cwd = options.cwd ?? process.cwd(); - const baseTools = createCodingTools(cwd).filter((tool) => tool.name !== "bash"); + const baseTools = createCodingTools(cwd).filter((tool) => tool.name !== "bash") as AgentTool[]; const execTool = createExecTool(cwd); const processTool = createProcessTool(cwd); return [...baseTools, execTool as AgentTool, processTool as AgentTool]; diff --git a/src/agent/tools/exec.ts b/src/agent/tools/exec.ts index f40aa007..fbbb1a80 100644 --- a/src/agent/tools/exec.ts +++ b/src/agent/tools/exec.ts @@ -28,7 +28,7 @@ export function createExecTool(defaultCwd?: string): AgentTool { const { command, cwd, timeoutMs } = args as ExecArgs; @@ -66,12 +66,29 @@ export function createExecTool(defaultCwd?: string): AgentTool { if (timeout) clearTimeout(timeout); - reject(err); + spawnError = err; + // 不 reject,让 close 事件处理 }); child.on("close", (code) => { if (timeout) clearTimeout(timeout); + + // 如果有 spawn 错误,返回错误信息 + if (spawnError) { + resolve({ + content: [{ type: "text", text: `Error: ${spawnError.message}` }], + details: { + output: `Error: ${spawnError.message}`, + exitCode: code ?? 1, + truncated: false, + }, + }); + return; + } + const output = Buffer.concat(chunks).toString("utf8"); resolve({ content: [{ type: "text", text: output || (timedOut ? "Process timed out." : "") }], diff --git a/src/agent/tools/process.ts b/src/agent/tools/process.ts index 42ab45e4..85061c1d 100644 --- a/src/agent/tools/process.ts +++ b/src/agent/tools/process.ts @@ -4,35 +4,40 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { v7 as uuidv7 } from "uuid"; const ProcessSchema = Type.Object({ - action: Type.String({ description: "Action: start | status | stop." }), - id: Type.Optional(Type.String({ description: "Process id for status/stop." })), + action: Type.String({ description: "Action: start | status | stop | output." }), + id: Type.Optional(Type.String({ description: "Process id for status/stop/output." })), command: Type.Optional(Type.String({ description: "Command to run for start." })), cwd: Type.Optional(Type.String({ description: "Working directory." })), }); +const MAX_OUTPUT_BUFFER = 64 * 1024; // 64KB per process + type ProcessEntry = { id: string; command: string; - cwd?: string; + cwd?: string | undefined; child: ChildProcess; exitCode: number | null; startedAt: number; + outputBuffer: string[]; + outputSize: number; }; const PROCESS_REGISTRY = new Map(); export type ProcessResult = { - id?: string; - running?: boolean; - exitCode?: number | null; - message?: string; + id?: string | undefined; + running?: boolean | undefined; + exitCode?: number | null | undefined; + message?: string | undefined; + output?: string | undefined; }; export function createProcessTool(defaultCwd?: string): AgentTool { return { name: "process", label: "Process", - description: "Manage background processes (start, status, stop).", + description: "Manage long-running background processes like servers, watchers, or daemons. Actions: 'start' to launch (returns immediately with process id), 'status' to check if running, 'output' to read stdout/stderr, 'stop' to terminate. Use this for servers (e.g., python server.py, npm run dev) instead of 'exec'.", parameters: ProcessSchema, execute: async (_toolCallId, params, signal) => { const action = String(params.action ?? "").toLowerCase(); @@ -47,29 +52,81 @@ export function createProcessTool(defaultCwd?: string): AgentTool { - entry.exitCode = code; - }); - if (signal) { - signal.addEventListener("abort", () => { - child.kill("SIGTERM"); + + // 使用 Promise 等待进程启动或失败 + const result = await new Promise<{ success: boolean; error?: string }>((resolve) => { + const child = spawn(command, { + shell: true, + cwd: params.cwd || defaultCwd, + stdio: ["ignore", "pipe", "pipe"], + detached: true, }); + + let resolved = false; + + // 处理 spawn 错误(如 shell 不存在) + child.on("error", (err) => { + if (!resolved) { + resolved = true; + resolve({ success: false, error: err.message }); + } + }); + + // 进程启动成功后注册到 registry + child.on("spawn", () => { + if (!resolved) { + resolved = true; + const entry: ProcessEntry = { + id, + command, + cwd: params.cwd || defaultCwd, + child, + exitCode: null, + startedAt: Date.now(), + outputBuffer: [], + outputSize: 0, + }; + PROCESS_REGISTRY.set(id, entry); + + // 收集输出到缓冲区 + const collectOutput = (data: Buffer) => { + const text = data.toString("utf8"); + if (entry.outputSize + text.length > MAX_OUTPUT_BUFFER) { + // 超出限制时,移除旧的输出 + while (entry.outputBuffer.length > 0 && entry.outputSize + text.length > MAX_OUTPUT_BUFFER) { + const removed = entry.outputBuffer.shift(); + if (removed) entry.outputSize -= removed.length; + } + } + entry.outputBuffer.push(text); + entry.outputSize += text.length; + }; + + child.stdout?.on("data", collectOutput); + child.stderr?.on("data", collectOutput); + + child.on("close", (code) => { + entry.exitCode = code; + }); + + if (signal) { + signal.addEventListener("abort", () => { + child.kill("SIGTERM"); + }); + } + + resolve({ success: true }); + } + }); + }); + + if (!result.success) { + return { + content: [{ type: "text", text: `Failed to start process: ${result.error}` }], + details: { id, running: false, message: result.error }, + }; } + return { content: [{ type: "text", text: `Started process ${id}` }], details: { id, running: true }, @@ -108,6 +165,23 @@ export function createProcessTool(defaultCwd?: string): AgentTool