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 <noreply@anthropic.com>
This commit is contained in:
parent
46a6cb3061
commit
200b2cefda
15 changed files with 740 additions and 58 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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] <prompt>");
|
||||
console.log("Usage: pnpm agent:cli [options] <prompt>");
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./runner.js";
|
||||
export * from "./types.js";
|
||||
export * from "./profile/index.js";
|
||||
|
|
|
|||
205
src/agent/profile-cli.ts
Normal file
205
src/agent/profile-cli.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Agent Profile CLI
|
||||
*
|
||||
* Commands:
|
||||
* new <id> Create a new profile with default templates
|
||||
* list List all profiles
|
||||
* show <id> Show profile contents
|
||||
* edit <id> 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 <command> [options]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" new <id> Create a new profile with default templates");
|
||||
console.log(" list List all profiles");
|
||||
console.log(" show <id> Show profile contents");
|
||||
console.log(" edit <id> 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 <id>");
|
||||
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 <id>`);
|
||||
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 <id>`);
|
||||
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 <id>");
|
||||
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 <id>");
|
||||
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);
|
||||
});
|
||||
155
src/agent/profile/index.ts
Normal file
155
src/agent/profile/index.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
94
src/agent/profile/storage.ts
Normal file
94
src/agent/profile/storage.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
40
src/agent/profile/templates.ts
Normal file
40
src/agent/profile/templates.ts
Normal file
|
|
@ -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;
|
||||
44
src/agent/profile/types.ts
Normal file
44
src/agent/profile/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void> = Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<typeof getModel>)(
|
||||
options.provider,
|
||||
options.model,
|
||||
);
|
||||
}
|
||||
return getModel("kimi-coding", "kimi-k2-thinking");
|
||||
}
|
||||
|
||||
export function resolveTools(options: AgentOptions) {
|
||||
export function resolveTools(options: AgentOptions): AgentTool<any>[] {
|
||||
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<any>[];
|
||||
const execTool = createExecTool(cwd);
|
||||
const processTool = createProcessTool(cwd);
|
||||
return [...baseTools, execTool as AgentTool<any>, processTool as AgentTool<any>];
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function createExecTool(defaultCwd?: string): AgentTool<typeof ExecSchema
|
|||
return {
|
||||
name: "exec",
|
||||
label: "Exec",
|
||||
description: "Execute a shell command and return its output.",
|
||||
description: "Execute a shell command and wait for it to complete. Returns stdout/stderr output. Use this for short-lived commands only (e.g., ls, cat, pip install). Do NOT use for long-running processes like servers - use the 'process' tool instead.",
|
||||
parameters: ExecSchema,
|
||||
execute: async (_toolCallId, args, signal) => {
|
||||
const { command, cwd, timeoutMs } = args as ExecArgs;
|
||||
|
|
@ -66,12 +66,29 @@ export function createExecTool(defaultCwd?: string): AgentTool<typeof ExecSchema
|
|||
|
||||
child.stdout?.on("data", handleData);
|
||||
child.stderr?.on("data", handleData);
|
||||
|
||||
let spawnError: Error | null = null;
|
||||
child.on("error", (err) => {
|
||||
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." : "") }],
|
||||
|
|
|
|||
|
|
@ -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<string, ProcessEntry>();
|
||||
|
||||
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<typeof ProcessSchema, ProcessResult> {
|
||||
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<typeof Process
|
|||
if (PROCESS_REGISTRY.has(id)) {
|
||||
throw new Error(`Process already exists: ${id}`);
|
||||
}
|
||||
const child = spawn(command, {
|
||||
shell: true,
|
||||
cwd: params.cwd || defaultCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true,
|
||||
});
|
||||
const entry: ProcessEntry = {
|
||||
id,
|
||||
command,
|
||||
cwd: params.cwd || defaultCwd,
|
||||
child,
|
||||
exitCode: null,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
PROCESS_REGISTRY.set(id, entry);
|
||||
child.on("close", (code) => {
|
||||
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<typeof Process
|
|||
};
|
||||
}
|
||||
|
||||
if (action === "output") {
|
||||
const id = String(params.id ?? "");
|
||||
const entry = PROCESS_REGISTRY.get(id);
|
||||
if (!entry) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Process not found: ${id}` }],
|
||||
details: { id, running: false },
|
||||
};
|
||||
}
|
||||
const output = entry.outputBuffer.join("");
|
||||
const running = entry.exitCode === null;
|
||||
return {
|
||||
content: [{ type: "text", text: output || "(no output)" }],
|
||||
details: { id, running, exitCode: entry.exitCode, output },
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,20 +2,26 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|||
|
||||
export type AgentRunResult = {
|
||||
text: string;
|
||||
error?: string;
|
||||
error?: string | undefined;
|
||||
};
|
||||
|
||||
export type AgentLogger = {
|
||||
stdout?: NodeJS.WritableStream;
|
||||
stderr?: NodeJS.WritableStream;
|
||||
stdout?: NodeJS.WritableStream | undefined;
|
||||
stderr?: NodeJS.WritableStream | undefined;
|
||||
};
|
||||
|
||||
export type AgentOptions = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
cwd?: string;
|
||||
sessionId?: string;
|
||||
logger?: AgentLogger;
|
||||
/** Agent Profile ID - 加载预定义的身份、人格、记忆等配置 */
|
||||
profileId?: string | undefined;
|
||||
/** Profile 基础目录,默认 ~/.super-multica/agent-profiles */
|
||||
profileBaseDir?: string | undefined;
|
||||
provider?: string | undefined;
|
||||
model?: string | undefined;
|
||||
/** System prompt,如果设置了 profileId 会自动从 profile 构建 */
|
||||
systemPrompt?: string | undefined;
|
||||
thinkingLevel?: ThinkingLevel | undefined;
|
||||
/** 命令执行目录 */
|
||||
cwd?: string | undefined;
|
||||
sessionId?: string | undefined;
|
||||
logger?: AgentLogger | undefined;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue