feat(agent): add pi-agent core integration and test CLI

This commit is contained in:
Jiayuan 2026-01-30 01:04:39 +08:00
parent f7d03aa427
commit 8c2b6563d2
5 changed files with 2339 additions and 9 deletions

View file

@ -6,6 +6,7 @@
"main": "dist/index.js",
"scripts": {
"dev": "tsx src/index.ts",
"agent:cli": "tsx src/agent/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",
@ -26,6 +27,9 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@mariozechner/pi-agent-core": "^0.50.3",
"@mariozechner/pi-ai": "^0.50.3",
"@mariozechner/pi-coding-agent": "^0.50.3",
"@nestjs/common": "^11.1.12",
"@nestjs/core": "^11.1.12",
"@nestjs/platform-express": "^11.1.12",

2021
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,163 @@
export class Agent {
constructor(public readonly name: string) {}
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai";
import { createCodingTools } from "@mariozechner/pi-coding-agent";
export type AgentRunResult = {
text: string;
error?: string;
};
export type AgentLogger = {
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
};
export type AgentOptions = {
provider?: string;
model?: string;
systemPrompt?: string;
thinkingLevel?: ThinkingLevel;
cwd?: string;
logger?: AgentLogger;
};
function extractText(message: AgentMessage | undefined): string {
if (!message || typeof message !== "object" || !("content" in message)) return "";
const content = (message as { content?: Array<{ type: string; text?: string }> }).content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("");
}
function toolDisplayName(name: string): string {
const map: Record<string, string> = {
read: "ReadFile",
write: "WriteFile",
edit: "EditFile",
bash: "Bash",
grep: "Grep",
find: "FindFiles",
ls: "ListDir",
};
return map[name] || name;
}
function formatToolArgs(name: string, args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record<string, unknown>;
const get = (key: string) => (record[key] !== undefined ? String(record[key]) : "");
switch (name) {
case "read":
return get("path") || get("file");
case "write":
return get("path") || get("file");
case "edit":
return get("path") || get("file");
case "grep":
return [get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
case "find":
return [get("glob") || get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
case "ls":
return get("path") || get("directory");
case "bash":
return get("command");
default:
return "";
}
}
function formatToolLine(name: string, args: unknown): string {
const title = toolDisplayName(name);
const argText = formatToolArgs(name, args);
return argText ? `• Used ${title} (${argText})` : `• Used ${title}`;
}
export class Agent {
private readonly agent: PiAgentCore;
private readonly stdout: NodeJS.WritableStream;
private readonly stderr: NodeJS.WritableStream;
private lastAssistantText = "";
private printedLen = 0;
private streaming = false;
constructor(options: AgentOptions = {}) {
this.stdout = options.logger?.stdout ?? process.stdout;
this.stderr = options.logger?.stderr ?? process.stderr;
this.agent = new PiAgentCore();
if (options.systemPrompt) this.agent.setSystemPrompt(options.systemPrompt);
if (options.thinkingLevel) this.agent.setThinkingLevel(options.thinkingLevel);
if (options.provider && options.model) {
this.agent.setModel(getModel(options.provider, options.model));
} else {
this.agent.setModel(getModel("kimi-coding", "kimi-k2-thinking"));
}
const cwd = options.cwd ?? process.cwd();
this.agent.setTools(createCodingTools(cwd));
this.agent.subscribe((event) => this.handleEvent(event));
}
async run(prompt: string): Promise<AgentRunResult> {
this.lastAssistantText = "";
await this.agent.prompt(prompt);
return { text: this.lastAssistantText, error: this.agent.state.error };
}
private handleEvent(event: AgentEvent) {
switch (event.type) {
case "message_start": {
const msg = event.message;
if (msg.role === "assistant") {
this.streaming = true;
this.printedLen = 0;
const text = extractText(msg);
if (text.length > 0) {
this.stdout.write(text);
this.printedLen = text.length;
}
}
break;
}
case "message_update": {
const msg = event.message;
if (msg.role === "assistant") {
const text = extractText(msg);
if (text.length > this.printedLen) {
this.stdout.write(text.slice(this.printedLen));
this.printedLen = text.length;
}
}
break;
}
case "message_end": {
const msg = event.message;
if (msg.role === "assistant") {
const text = extractText(msg);
if (text.length > this.printedLen) {
this.stdout.write(text.slice(this.printedLen));
this.printedLen = text.length;
}
if (this.streaming) this.stdout.write("\n");
this.streaming = false;
this.lastAssistantText = text;
}
break;
}
case "tool_execution_start":
this.stderr.write(`${formatToolLine(event.toolName, event.args)}\n`);
break;
case "tool_execution_end":
if (event.isError) {
const errorText = extractText(event.result) || "Tool failed";
this.stderr.write(`• Tool error (${toolDisplayName(event.toolName)}): ${errorText}\n`);
}
break;
default:
break;
}
}
}

103
src/agent/cli.ts Normal file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env node
import { Agent } from "./agent.js";
type CliOptions = {
provider?: string;
model?: string;
system?: string;
thinking?: string;
cwd?: string;
help?: boolean;
};
function printUsage() {
console.log("Usage: pnpm agent:cli [--provider PROVIDER] [--model MODEL] [--system TEXT] [--thinking LEVEL] [--cwd DIR] <prompt>");
console.log(" echo \"your prompt\" | pnpm agent:cli");
}
function parseArgs(argv: string[]) {
const args = [...argv];
const opts: CliOptions = {};
const promptParts: string[] = [];
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--help" || arg === "-h") {
opts.help = true;
break;
}
if (arg === "--provider") {
opts.provider = args.shift();
continue;
}
if (arg === "--model") {
opts.model = args.shift();
continue;
}
if (arg === "--system") {
opts.system = args.shift();
continue;
}
if (arg === "--thinking") {
opts.thinking = args.shift();
continue;
}
if (arg === "--cwd") {
opts.cwd = args.shift();
continue;
}
if (arg === "--") {
promptParts.push(...args);
break;
}
promptParts.push(arg);
}
return { opts, prompt: promptParts.join(" ") };
}
async function readStdin() {
if (process.stdin.isTTY) return "";
return new Promise<string>((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => resolve(data.trim()));
process.stdin.on("error", reject);
});
}
async function main() {
const { opts, prompt } = parseArgs(process.argv.slice(2));
if (opts.help) {
printUsage();
return;
}
const stdinPrompt = await readStdin();
const finalPrompt = prompt || stdinPrompt;
if (!finalPrompt) {
printUsage();
process.exit(1);
}
const agent = new Agent({
provider: opts.provider,
model: opts.model,
systemPrompt: opts.system,
thinkingLevel: opts.thinking as any,
cwd: opts.cwd,
});
const result = await agent.run(finalPrompt);
if (result.error) {
console.error(`Error: ${result.error}`);
process.exitCode = 1;
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -1,33 +1,53 @@
import { v7 as uuidv7 } from "uuid";
import { Agent as CoreAgent } from "../agent/agent.js";
import { Channel } from "./channel.js";
import type { Message } from "./types.js";
/**
* Mock Agent
* write() channelread() channel
* Agent 使 pi-agent-core
* write() read()
*/
export class Agent {
readonly id: string;
private readonly channel = new Channel<Message>();
private _closed = false;
private readonly agent: CoreAgent;
private queue: Promise<void> = Promise.resolve();
constructor(id?: string) {
this.id = id ?? uuidv7();
this.agent = new CoreAgent({
logger: {
stdout: this.createChannelStream("[assistant] "),
stderr: this.createChannelStream("[tool] "),
},
});
}
get closed(): boolean {
return this._closed;
}
/** 写入消息到 agent非阻塞 */
/** 写入消息到 agent非阻塞,串行排队 */
write(content: string): void {
if (this._closed) {
throw new Error("Agent is closed");
}
this.channel.send({
id: uuidv7(),
content: `[mock-agent:${this.id}] echo: ${content}`,
});
this.queue = this.queue
.then(async () => {
const result = await this.agent.run(content);
if (result.error) {
this.channel.send({
id: uuidv7(),
content: `[error] ${result.error}`,
});
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
this.channel.send({ id: uuidv7(), content: `[error] ${message}` });
});
}
/** 持续读取消息流 */
@ -41,4 +61,26 @@ export class Agent {
this._closed = true;
this.channel.close();
}
private createChannelStream(prefix: string): NodeJS.WritableStream {
let buffer = "";
return {
write: (chunk: any) => {
if (this._closed) return false;
const text =
typeof chunk === "string"
? chunk
: chunk?.toString?.() ?? String(chunk);
if (!text) return true;
buffer += text;
const parts = buffer.split("\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
if (part.length === 0) continue;
this.channel.send({ id: uuidv7(), content: `${prefix}${part}` });
}
return true;
},
} as NodeJS.WritableStream;
}
}