refactor(agent): Pi 会话运行时 - 接入原生 Agent 与 Skills 工具链
This commit is contained in:
1 parent
f9b8c5a43e
commit
387d3562ad
69 files changed
+11460
-207
No files matched your search
@@ -0,0 +1,424 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
|
||||
const providerID = "infinite-canvas";
|
||||
const api = "openai-completions";
|
||||
|
||||
async function readRequest() {
|
||||
let raw = "";
|
||||
for await (const chunk of process.stdin) raw += chunk;
|
||||
if (!raw.trim()) throw new Error("Agent runtime input is empty");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function emit(event, payload = {}) {
|
||||
process.stdout.write(`${JSON.stringify({ event, ...payload })}\n`);
|
||||
}
|
||||
|
||||
function createMessage(model, result) {
|
||||
const content = [];
|
||||
if (result.text) content.push({ type: "text", text: result.text });
|
||||
for (const call of result.toolCalls ?? []) {
|
||||
content.push({
|
||||
type: "toolCall",
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
arguments: call.arguments ?? {},
|
||||
});
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: Number(result.usage?.input ?? 0),
|
||||
output: Number(result.usage?.output ?? 0),
|
||||
cacheRead: Number(result.usage?.cacheRead ?? 0),
|
||||
cacheWrite: Number(result.usage?.cacheWrite ?? 0),
|
||||
totalTokens: Number(result.usage?.totalTokens ?? 0),
|
||||
cost: result.usage?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: content.some((item) => item.type === "toolCall") ? "toolUse" : "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function bridge(request, path, body, signal) {
|
||||
const response = await fetch(new URL(path, request.bridgeURL), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${request.bridgeToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Agent bridge returned HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const request = await readRequest();
|
||||
if (!request.bridgeURL || !request.bridgeToken || !request.model?.id) {
|
||||
throw new Error("Agent runtime is missing its bridge, session, or model configuration");
|
||||
}
|
||||
|
||||
const modelRuntime = await ModelRuntime.create({ agentDir: request.agentDir });
|
||||
modelRuntime.registerProvider(providerID, {
|
||||
name: "影策模型任务",
|
||||
baseUrl: "http://agent-runtime.invalid/v1",
|
||||
apiKey: "managed-by-go-bridge",
|
||||
api,
|
||||
authHeader: false,
|
||||
models: [{
|
||||
id: request.model.id,
|
||||
name: request.model.name || request.model.id,
|
||||
api,
|
||||
reasoning: Boolean(request.model.reasoning),
|
||||
input: request.model.input?.length ? request.model.input : ["text", "image"],
|
||||
cost: request.model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: Math.max(1, Number(request.model.contextWindow || 128000)),
|
||||
maxTokens: Math.max(1, Number(request.model.maxTokens || 8192)),
|
||||
}],
|
||||
streamSimple(model, context, options) {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
void (async () => {
|
||||
stream.push({ type: "start", partial });
|
||||
try {
|
||||
const result = await bridge(request, "/model", {
|
||||
modelId: model.id,
|
||||
messages: context.messages,
|
||||
tools: (context.tools ?? []).map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
})),
|
||||
thinkingLevel: options?.reasoning,
|
||||
}, options?.signal);
|
||||
for (const text of result.steeringMessages ?? []) {
|
||||
await session.steer(text);
|
||||
}
|
||||
const message = createMessage(model, result);
|
||||
if (result.text) {
|
||||
partial.content.push({ type: "text", text: result.text });
|
||||
stream.push({ type: "text_start", contentIndex: 0, partial });
|
||||
stream.push({ type: "text_delta", contentIndex: 0, delta: result.text, partial });
|
||||
stream.push({ type: "text_end", contentIndex: 0, content: result.text, partial });
|
||||
}
|
||||
let contentIndex = result.text ? 1 : 0;
|
||||
for (const call of result.toolCalls ?? []) {
|
||||
const toolCall = message.content.find((item) => item.type === "toolCall" && item.id === call.id);
|
||||
partial.content.push(toolCall);
|
||||
stream.push({ type: "toolcall_start", contentIndex, partial });
|
||||
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial });
|
||||
contentIndex++;
|
||||
}
|
||||
stream.push({ type: "done", reason: message.stopReason, message });
|
||||
stream.end(message);
|
||||
} catch (error) {
|
||||
stream.push({ type: "error", reason: "error", error: {
|
||||
...partial,
|
||||
stopReason: "error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
} });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
const model = modelRuntime.getModel(providerID, request.model.id);
|
||||
if (!model) throw new Error("Agent model registration failed");
|
||||
|
||||
let session;
|
||||
// 工具进入审批时主动中止本轮;这是暂停,不是失败。
|
||||
let pausedForApproval = false;
|
||||
const tools = (request.tools ?? []).map((tool) => ({
|
||||
name: tool.name,
|
||||
label: tool.label || tool.name,
|
||||
description: tool.description || tool.name,
|
||||
parameters: Type.Unsafe(tool.parameters ?? { type: "object", properties: {} }),
|
||||
executionMode: tool.executionMode || "sequential",
|
||||
async execute(toolCallId, params, signal) {
|
||||
const result = await bridge(request, "/tool", {
|
||||
callId: toolCallId,
|
||||
name: tool.name,
|
||||
arguments: params,
|
||||
}, signal);
|
||||
if (result.pause) {
|
||||
pausedForApproval = true;
|
||||
emit("approval_wait", { approvalId: result.approvalId, toolName: tool.name, callId: toolCallId });
|
||||
session.abort();
|
||||
return {
|
||||
content: [{ type: "text", text: result.content || "操作正在等待用户审批。" }],
|
||||
details: { approvalId: result.approvalId, paused: true },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// 工具结果必须是非空文本:交回 "null" 会让模型返回空回复。
|
||||
let text = typeof result.content === "string" ? result.content : JSON.stringify(result.content ?? result ?? {});
|
||||
if (!text || text === "null") text = JSON.stringify({ error: "工具没有返回结果" });
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: result.details,
|
||||
isError: Boolean(result.isError),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
let sessionManager;
|
||||
if (request.sessionJSONL) {
|
||||
if (!request.sessionFile) throw new Error("Agent session restore requires a session file path");
|
||||
await mkdir(dirname(request.sessionFile), { recursive: true, mode: 0o700 });
|
||||
await writeFile(request.sessionFile, request.sessionJSONL, { mode: 0o600, flag: "w" });
|
||||
sessionManager = SessionManager.open(request.sessionFile, undefined, request.cwd);
|
||||
} else {
|
||||
sessionManager = SessionManager.create(request.cwd, request.sessionDir);
|
||||
}
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: request.cwd,
|
||||
agentDir: request.agentDir,
|
||||
noExtensions: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
additionalSkillPaths: request.skillPaths ?? [],
|
||||
systemPrompt: request.systemPrompt,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const contextWindow = Math.max(1, Number(request.model?.contextWindow || 128000));
|
||||
const sessionConfig = {
|
||||
cwd: request.cwd,
|
||||
agentDir: request.agentDir,
|
||||
modelRuntime,
|
||||
model,
|
||||
resourceLoader,
|
||||
sessionManager,
|
||||
customTools: tools,
|
||||
};
|
||||
|
||||
if (request.profile) {
|
||||
sessionConfig.profile = {
|
||||
revision: request.profile.revision,
|
||||
hash: request.profile.hash,
|
||||
layers: request.profile.layers || [],
|
||||
};
|
||||
}
|
||||
|
||||
if (request.memory?.enabled) {
|
||||
sessionConfig.memory = {
|
||||
enabled: true,
|
||||
storePath: request.memory.storePath,
|
||||
userId: request.userId,
|
||||
canvasId: request.canvasId,
|
||||
maxEntries: request.memory.maxEntries || 1000,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.canvas) {
|
||||
sessionConfig.canvas = request.canvas;
|
||||
}
|
||||
|
||||
if (request.features) {
|
||||
sessionConfig.features = {
|
||||
planning: request.features.planningEnabled ?? true,
|
||||
forms: request.features.formsEnabled ?? true,
|
||||
approval: request.features.approvalRequired ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
if (request.permissions) {
|
||||
sessionConfig.permissions = request.permissions;
|
||||
}
|
||||
|
||||
({ session } = await createAgentSession(sessionConfig));
|
||||
|
||||
const compactionConfig = request.compaction || {
|
||||
enabled: true,
|
||||
strategy: "balanced",
|
||||
reserveTokens: Math.max(1024, Math.ceil(contextWindow * 0.2)),
|
||||
keepRecentTokens: Math.min(20000, Math.max(1024, Math.floor(contextWindow * 0.2))),
|
||||
};
|
||||
|
||||
session.settingsManager.applyOverrides({
|
||||
compaction: compactionConfig,
|
||||
// 重试只由 Go 模型桥决定(按上游真实 HTTP 状态区分临时故障与参数错误);
|
||||
// 这里再重试一层会让 400 也被重发、让 500 被重试 3×3 次。
|
||||
retry: { enabled: false },
|
||||
});
|
||||
|
||||
const eventChain = { current: Promise.resolve() };
|
||||
let runtimeError = null;
|
||||
const enqueueEvent = (payload) => {
|
||||
eventChain.current = eventChain.current.then(() => bridge(request, "/event", payload));
|
||||
};
|
||||
|
||||
session.subscribe((event) => {
|
||||
// 转发完整的 Pi 事件流到 Go 后端
|
||||
if (event.type === "message_start") {
|
||||
const message = event.message;
|
||||
if (message?.role === "assistant") {
|
||||
enqueueEvent({
|
||||
type: "message_start",
|
||||
role: "assistant",
|
||||
messageId: event.messageId,
|
||||
});
|
||||
}
|
||||
} else if (event.type === "message_delta") {
|
||||
enqueueEvent({
|
||||
type: "message_delta",
|
||||
delta: event.delta,
|
||||
messageId: event.messageId,
|
||||
});
|
||||
} else if (event.type === "message_end") {
|
||||
const message = event.message;
|
||||
if (message?.role !== "assistant") return;
|
||||
if (pausedForApproval || message.stopReason === "aborted") return;
|
||||
if (message.stopReason === "error" || message.errorMessage) {
|
||||
runtimeError = new Error(message.errorMessage || "Agent assistant message failed");
|
||||
return;
|
||||
}
|
||||
enqueueEvent({
|
||||
type: "message_end",
|
||||
message,
|
||||
contextUsage: session.getContextUsage(),
|
||||
});
|
||||
} else if (event.type === "thinking_start") {
|
||||
enqueueEvent({
|
||||
type: "thinking_start",
|
||||
thinkingId: event.thinkingId,
|
||||
});
|
||||
} else if (event.type === "thinking_delta") {
|
||||
enqueueEvent({
|
||||
type: "thinking_delta",
|
||||
delta: event.delta,
|
||||
thinkingId: event.thinkingId,
|
||||
});
|
||||
} else if (event.type === "thinking_end") {
|
||||
enqueueEvent({
|
||||
type: "thinking_end",
|
||||
content: event.content,
|
||||
thinkingId: event.thinkingId,
|
||||
});
|
||||
} else if (event.type === "planning_start") {
|
||||
enqueueEvent({
|
||||
type: "planning_start",
|
||||
planId: event.planId,
|
||||
});
|
||||
} else if (event.type === "planning_update") {
|
||||
enqueueEvent({
|
||||
type: "planning_update",
|
||||
plan: event.plan,
|
||||
planId: event.planId,
|
||||
});
|
||||
} else if (event.type === "planning_end") {
|
||||
enqueueEvent({
|
||||
type: "planning_end",
|
||||
plan: event.plan,
|
||||
planId: event.planId,
|
||||
});
|
||||
} else if (event.type === "form_start") {
|
||||
enqueueEvent({
|
||||
type: "form_start",
|
||||
form: event.form,
|
||||
formId: event.formId,
|
||||
});
|
||||
} else if (event.type === "form_update") {
|
||||
enqueueEvent({
|
||||
type: "form_update",
|
||||
form: event.form,
|
||||
formId: event.formId,
|
||||
});
|
||||
} else if (event.type === "form_submit") {
|
||||
enqueueEvent({
|
||||
type: "form_submit",
|
||||
answers: event.answers,
|
||||
formId: event.formId,
|
||||
});
|
||||
} else if (event.type === "tool_call_start") {
|
||||
enqueueEvent({
|
||||
type: "tool_call_start",
|
||||
toolCall: event.toolCall,
|
||||
callId: event.callId,
|
||||
});
|
||||
} else if (event.type === "tool_call_end") {
|
||||
enqueueEvent({
|
||||
type: "tool_call_end",
|
||||
result: event.result,
|
||||
callId: event.callId,
|
||||
});
|
||||
} else if (event.type === "approval_requested") {
|
||||
enqueueEvent({
|
||||
type: "approval_requested",
|
||||
approval: event.approval,
|
||||
approvalId: event.approvalId,
|
||||
});
|
||||
} else if (event.type === "entry_appended") {
|
||||
eventChain.current = eventChain.current.then(async () => {
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
if (!sessionFile) return;
|
||||
let sessionJSONL;
|
||||
try {
|
||||
sessionJSONL = await readFile(sessionFile, "utf8");
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
return bridge(request, "/event", {
|
||||
type: "session_snapshot",
|
||||
sessionJSONL,
|
||||
contextUsage: session.getContextUsage(),
|
||||
});
|
||||
});
|
||||
} else if (event.type === "compaction_start" || event.type === "compaction_end") {
|
||||
enqueueEvent({ type: event.type, reason: event.reason, contextUsage: session.getContextUsage() });
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
await session.prompt(request.prompt);
|
||||
} catch (error) {
|
||||
if (!pausedForApproval) throw error;
|
||||
}
|
||||
await eventChain.current;
|
||||
if (runtimeError && !pausedForApproval) throw runtimeError;
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
const sessionJSONL = await readFile(sessionFile, "utf8");
|
||||
await bridge(request, "/event", {
|
||||
type: "session_snapshot",
|
||||
sessionJSONL,
|
||||
contextUsage: session.getContextUsage(),
|
||||
});
|
||||
emit("settled", {
|
||||
contextUsage: session.getContextUsage(),
|
||||
sessionFile,
|
||||
entries: sessionManager.getEntries().length,
|
||||
});
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
emit("runtime_error", { message: error instanceof Error ? error.message : String(error) });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user