feat(agent): add pi-agent core integration and test CLI
This commit is contained in:
parent
f7d03aa427
commit
8c2b6563d2
5 changed files with 2339 additions and 9 deletions
|
|
@ -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
2021
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
103
src/agent/cli.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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() 将消息放入 channel,read() 从 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue