Files

528 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { rmSync } from "node:fs";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent, setGlobalDispatcher } from "undici";
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { Type } from "typebox";
// Node 自带 undici 的 header/body 超时默认 300 秒,短于文本任务 8 分钟和视频任务 60 分钟。
// 设为 0 后只由 Go 侧任务期限取消 bridge;这里不再提前切断仍在排队或生成中的步骤。
setGlobalDispatcher(new Agent({
connectTimeout: 30_000,
headersTimeout: 0,
bodyTimeout: 0,
}));
const providerID = "yingce";
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,
...(call.item_id ? { itemId: call.item_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");
}
// 每次运行使用独立的空配置目录:SDK 的 auth.json / models.json / 锁文件都落在这里,
// 不读取宿主 ~/.pi,也不会执行预置 auth.json 里的 !command。运行结束即删除。
// 会话文件和工作目录同样放在这里:它们只是本轮的工作副本,真实来源是服务端
// 数据库里的会话快照(请求带 sessionJSONL,运行中回传)。不使用服务端的路径:
// 独立容器里没有服务端的数据目录;空工作目录也不会被发现任何项目级配置或技能。
const isolatedDir = await mkdtemp(join(tmpdir(), "agent-runtime-"));
process.env.HOME = isolatedDir;
process.env.PI_CODING_AGENT_DIR = isolatedDir;
process.on("exit", () => {
try { rmSync(isolatedDir, { recursive: true, force: true }); } catch {}
});
const agentDir = isolatedDir;
const workDir = join(isolatedDir, "work");
const sessionDir = join(isolatedDir, "sessions");
await mkdir(workDir, { mode: 0o700 });
await mkdir(sessionDir, { mode: 0o700 });
// Lifecycle state, not prompt wording, identifies SDK summarization calls.
// Keep the id through the closing event delivery so the requested/end pair
// can always be correlated, while the closing phase no longer labels a
// following normal model request as a summary request.
let activeCompaction = null;
const eventChain = { current: Promise.resolve() };
const modelRuntime = await ModelRuntime.create({
authPath: join(isolatedDir, "auth.json"),
modelsPath: join(isolatedDir, "models.json"),
});
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 });
// 审批暂停时 session.abort() 是异步生效的,SDK 可能在它生效前就开下一轮。
// 这一轮不能发给服务端:运行正在等审批,再跑一步会多计一次费,
// 新的工具调用还会和待审批的调用对不上,让整轮以检查点校验失败收场。
if (pausedForApproval) {
stream.push({ type: "error", reason: "aborted", error: { ...partial, stopReason: "aborted", errorMessage: "paused for approval" } });
stream.end();
return;
}
try {
const purpose = activeCompaction?.phase === "summarizing" ? "compaction" : "conversation";
// Deliver lifecycle events/snapshots before scheduling the next request.
// In particular, compaction must be visible before its model call begins.
await eventChain.current;
const result = await bridge(request, "/model", {
modelId: model.id,
purpose,
systemPrompt: context.systemPrompt,
messages: context.messages,
tools: (context.tools ?? []).map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
})),
thinkingLevel: options?.reasoning ?? "off",
contextUsage: session.getContextUsage(),
}, 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,
terminate: 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),
terminate: Boolean(result.terminate),
};
},
}));
let sessionManager;
if (request.sessionJSONL) {
const sessionFile = join(sessionDir, "session.jsonl");
await writeFile(sessionFile, request.sessionJSONL, { mode: 0o600, flag: "wx" });
sessionManager = SessionManager.open(sessionFile, sessionDir, workDir);
} else {
sessionManager = SessionManager.create(workDir, sessionDir);
}
// 严格隔离:不探索文件系统的 packages/skills/extensions/项目配置。
// 技能由服务端的读取工具提供,这里不再按路径加载;projectTrusted=false 让 SDK
// 不读取 <cwd>/.pi 设置,也不向上层目录收集 .agents/skills。
// 同一个设置管理器也交给 createAgentSession,否则 SDK 会另建一个读文件的实例。
const settingsManager = SettingsManager.inMemory({}, { projectTrusted: false });
const resourceLoader = new DefaultResourceLoader({
cwd: workDir,
agentDir,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
additionalSkillPaths: [],
systemPrompt: request.systemPrompt,
settingsManager,
});
await resourceLoader.reload();
const contextWindow = Math.max(1, Number(request.model?.contextWindow || 128000));
const sessionConfig = {
cwd: workDir,
agentDir,
settingsManager,
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;
}
// 只启用平台声明的工具,不使用 SDK 默认工具
const platformToolNames = tools.map(t => t.name);
sessionConfig.tools = platformToolNames;
({ 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 },
});
let runtimeError = null;
// 事件链串行执行。已有一次快照在排队时,它执行时读到的已是最新文件,
// 不必为每条新条目再整份上传一次。
let snapshotQueued = false;
const enqueueEvent = (payload) => {
const pending = eventChain.current.then(() => bridge(request, "/event", payload));
eventChain.current = pending;
return pending;
};
const enqueueSessionSnapshot = () => {
if (snapshotQueued) return;
snapshotQueued = true;
eventChain.current = eventChain.current.then(async () => {
snapshotQueued = false;
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(),
});
});
};
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") {
enqueueSessionSnapshot();
} else if (event.type === "compaction_start" || event.type === "compaction_end") {
// Persist the compaction entry before reporting it to the server. If event
// delivery fails, the summary and retained tail are still recoverable.
if (event.type === "compaction_start") activeCompaction = { id: crypto.randomUUID(), phase: "summarizing" };
const compaction = activeCompaction;
if (event.type === "compaction_end" && compaction) compaction.phase = "closing";
if (event.type === "compaction_end") enqueueSessionSnapshot();
const eventDelivery = enqueueEvent({
type: event.type,
compactionId: compaction?.id,
reason: event.reason,
aborted: event.aborted,
willRetry: event.willRetry,
errorMessage: event.errorMessage,
hasResult: Boolean(event.result),
tokensBefore: event.result?.tokensBefore,
estimatedTokensAfter: event.result?.estimatedTokensAfter,
contextUsage: session.getContextUsage(),
});
if (event.type === "compaction_end" && compaction) {
eventDelivery.then(
() => {
if (activeCompaction === compaction) activeCompaction = null;
},
() => {
if (activeCompaction === compaction) activeCompaction = null;
},
);
}
}
});
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;
});