Files
pikasTech-HWLAB/internal/cloud/code-agent-chat.mjs
T
2026-05-23 00:27:25 +00:00

683 lines
20 KiB
JavaScript

import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { access, mkdir, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEV_CODE_AGENT_PROVIDER_CONTRACT,
codeAgentSecretRefPlaceholder,
inspectCodeAgentProviderEnv
} from "./code-agent-contract.mjs";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000;
const DEFAULT_CODEX_COMMAND = "codex";
const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model;
const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench";
const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", "");
const CODE_AGENT_SYSTEM_PROMPT = [
"你是 HWLAB 云工作台的 Code Agent。",
"请用中文直接回答用户的工作台问题。",
"当前最小版本可以帮助用户整理任务、查看云工作台资源和说明下一步;不要声称已经执行硬件变更。",
"不要输出 secret、token 或环境变量原文。"
].join("\n");
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export async function handleCodeAgentChat(params = {}, options = {}) {
const timestamp = nowIso(options.now);
const conversationId = cleanProtocolId(params.conversationId, "cnv") || `cnv_${randomUUID()}`;
const sessionId = conversationId;
const messageId = `msg_${randomUUID()}`;
const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`;
const providerPlan = resolveProviderPlan(options.env ?? process.env, options);
const base = {
conversationId,
sessionId,
messageId,
status: "running",
createdAt: timestamp,
updatedAt: timestamp,
traceId,
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
projectId: cleanProjectId(params.projectId) || DEFAULT_PROJECT_ID
};
try {
const message = normalizeUserMessage(params.message);
const providerResult = await callConfiguredProvider({
providerPlan,
message,
conversationId,
traceId,
timeoutMs: options.timeoutMs,
env: options.env ?? process.env,
now: options.now,
callProvider: options.callProvider
});
const content = typeof providerResult.content === "string" ? providerResult.content.trim() : "";
if (!content) {
throw providerUnavailable("Code Agent provider returned no assistant text", {
provider: providerResult.provider ?? base.provider,
model: providerResult.model ?? base.model,
backend: providerResult.backend ?? base.backend
});
}
const completedAt = nowIso(options.now);
return {
...base,
status: "completed",
updatedAt: completedAt,
provider: providerResult.provider ?? base.provider,
model: providerResult.model ?? base.model,
backend: providerResult.backend ?? base.backend,
reply: {
messageId,
role: "assistant",
content,
createdAt: completedAt
},
usage: providerResult.usage ?? null,
providerTrace: providerResult.providerTrace ?? null
};
} catch (error) {
const failedAt = nowIso(options.now);
const payload = {
...base,
status: "failed",
updatedAt: failedAt,
error: normalizeChatError(error)
};
if (error.code === "provider_unavailable") {
payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options);
}
return payload;
}
}
export function validateCodeAgentChatSchema(payload) {
const required = [
"conversationId",
"sessionId",
"messageId",
"status",
"createdAt",
"updatedAt",
"traceId",
"provider",
"model",
"backend"
];
for (const field of required) {
if (!payload || typeof payload !== "object" || !Object.hasOwn(payload, field)) {
throw new Error(`code agent chat response missing ${field}`);
}
}
if (!["running", "completed", "failed"].includes(payload.status)) {
throw new Error(`code agent chat response has invalid status ${JSON.stringify(payload.status)}`);
}
if (Number.isNaN(Date.parse(payload.createdAt)) || Number.isNaN(Date.parse(payload.updatedAt))) {
throw new Error("code agent chat response timestamps must be RFC 3339 strings");
}
if (payload.status === "failed" && typeof payload.error?.message !== "string") {
throw new Error("failed code agent chat response must include error.message");
}
if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) {
throw new Error("completed code agent chat response must include non-empty reply.content");
}
}
export function describeCodeAgentAvailability(env = process.env, options = {}) {
const providerPlan = resolveProviderPlan(env, options);
const providerContract = inspectCodeAgentProviderEnv(env);
const missingEnv = providerPlan.mode === "openai"
? providerContract.missingEnv
: [];
if (providerPlan.mode !== "openai" && !env.OPENAI_API_KEY) {
missingEnv.push("OPENAI_API_KEY");
}
const blocked = providerPlan.mode === "openai"
? !providerContract.ready
: missingEnv.length > 0;
return {
endpoint: "POST /v1/agent/chat",
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
mode: providerPlan.mode,
schema: [
"conversationId",
"sessionId",
"messageId",
"status",
"createdAt",
"updatedAt",
"traceId",
"provider",
"model",
"backend",
"error.message",
"error.code",
"error.missingEnv",
"availability.status"
],
status: blocked ? "blocked" : "available",
blocker: blocked ? (providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口") : null,
reason: blocked ? "provider_unavailable" : null,
summary: blocked
? `真实后端已接入,但当前 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 未满足,因此不能返回真实回复。`
: "真实后端已接入,发送会走 DEV egress/proxy 后的真实 /v1/agent/chat;只有实际 completed 回复才标记 DEV-LIVE。",
missingEnv,
secretRefs: blocked ? providerContract.secretRefs : [],
egress: providerContract.egress,
safety: providerContract.safety,
ready: !blocked
};
}
function resolveProviderPlan(env, options = {}) {
const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase();
const model = firstNonEmpty(
options.model,
env.HWLAB_CODE_AGENT_MODEL,
env.OPENAI_MODEL,
env.CODE_QUEUE_DEFAULT_MODEL,
DEFAULT_MODEL
);
if (provider === "openai") {
return {
provider: "openai-responses",
model,
backend: "hwlab-cloud-api/openai-responses",
mode: "openai"
};
}
if (provider === "codex-cli" || provider === "codex") {
return {
provider: "codex-cli",
model,
backend: "hwlab-cloud-api/codex-cli",
mode: "codex-cli"
};
}
return {
provider: env.OPENAI_API_KEY ? "openai-responses" : "codex-cli",
model,
backend: env.OPENAI_API_KEY ? "hwlab-cloud-api/openai-responses" : "hwlab-cloud-api/codex-cli",
mode: env.OPENAI_API_KEY ? "openai" : "codex-cli",
auto: true
};
}
async function callConfiguredProvider({
providerPlan,
message,
conversationId,
traceId,
timeoutMs,
env,
now,
callProvider
}) {
if (callProvider) {
return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now });
}
if (providerPlan.mode === "openai") {
return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env });
}
return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env });
}
async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
const providerContract = inspectCodeAgentProviderEnv(env);
if (!providerContract.ready) {
throw providerUnavailable(providerContract.blocker, {
missingEnv: providerContract.missingEnv,
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
egress: providerContract.egress
});
}
const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), effectiveTimeout(timeoutMs));
let response;
let payload;
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "text/event-stream",
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: providerPlan.model,
instructions: CODE_AGENT_SYSTEM_PROMPT,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: buildAgentPrompt({ message, conversationId, traceId })
}
]
}
],
store: false,
stream: true
}),
signal: controller.signal
});
payload = parseOpenAiResponseBody(await response.text());
} catch (error) {
if (error.name === "AbortError") {
throw providerUnavailable(`OpenAI Responses request timed out after ${effectiveTimeout(timeoutMs)}ms`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
throw providerUnavailable(`OpenAI Responses request failed: ${error.message}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
} finally {
clearTimeout(timer);
}
const providerError = payload.error ?? payload.raw?.error ?? null;
if (!response.ok) {
throw providerUnavailable(`OpenAI Responses returned HTTP ${response.status}: ${providerError?.message || "request rejected"}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
providerStatus: response.status
});
}
if (providerError) {
throw providerUnavailable(`OpenAI Responses stream error: ${providerError.message || "request rejected"}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
const content = payload.content || extractOpenAiOutputText(payload.raw);
if (!content) {
throw providerUnavailable("OpenAI Responses returned no assistant text", {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
return {
provider: providerPlan.provider,
model: payload.model || providerPlan.model,
backend: providerPlan.backend,
content,
usage: payload.usage ?? null,
providerTrace: {
responseId: payload.responseId ?? null
}
};
}
async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND);
if (!(await commandExists(command, env))) {
throw providerUnavailable(`Codex CLI command is not available: ${command}`, {
missingCommands: [command],
missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"],
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command
});
}
const outputDir = await makeTempDir();
const outputFile = path.join(outputDir, "last-message.txt");
const args = [
"exec",
"--cd",
repoRoot,
"--skip-git-repo-check",
"--sandbox",
"read-only",
"--ephemeral",
"--output-last-message",
outputFile
];
if (providerPlan.model) {
args.push("--model", providerPlan.model);
}
args.push("-");
const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId }), {
env,
timeoutMs: effectiveTimeout(timeoutMs)
});
try {
if (result.code !== 0) {
throw providerUnavailable(`Codex CLI exited with code ${result.code}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command: commandLine(command, args),
exitCode: result.code,
stderrSummary: redactText(tailText(result.stderr || result.stdout))
});
}
const content = (await readFile(outputFile, "utf8")).trim();
if (!content) {
throw providerUnavailable("Codex CLI completed without an assistant message", {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command: commandLine(command, args),
stderrSummary: redactText(tailText(result.stderr || result.stdout))
});
}
return {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
content,
usage: null,
providerTrace: {
command: commandLine(command, args),
stdoutSummary: redactText(tailText(result.stdout, 600))
}
};
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}
function buildAgentPrompt({ message, conversationId, traceId }) {
return [
CODE_AGENT_SYSTEM_PROMPT,
"",
`conversationId: ${conversationId}`,
`traceId: ${traceId}`,
"",
"用户消息:",
message
].join("\n");
}
function normalizeUserMessage(value) {
if (typeof value !== "string") {
throw badRequest("message must be a string");
}
const message = value.trim();
if (!message) {
throw badRequest("message is required");
}
if (message.length > 4000) {
throw badRequest("message exceeds 4000 characters");
}
return message;
}
function normalizeChatError(error) {
const normalized = {
code: error.code || "code_agent_failed",
message: error.message || "Code Agent request failed"
};
for (const key of [
"missingEnv",
"missingCommands",
"provider",
"model",
"backend",
"command",
"exitCode",
"providerStatus",
"stderrSummary"
]) {
if (error[key] !== undefined) {
normalized[key] = error[key];
}
}
return normalized;
}
function badRequest(message) {
const error = new Error(message);
error.code = "invalid_params";
return error;
}
function providerUnavailable(message, details = {}) {
const error = new Error(message);
error.code = "provider_unavailable";
Object.assign(error, details);
return error;
}
function extractOpenAiOutputText(payload) {
if (typeof payload?.output_text === "string" && payload.output_text.trim()) {
return payload.output_text.trim();
}
const chunks = [];
for (const item of payload?.output ?? []) {
for (const content of item.content ?? []) {
if (typeof content.text === "string") chunks.push(content.text);
else if (typeof content.output_text === "string") chunks.push(content.output_text);
}
}
return chunks.join("\n").trim();
}
function parseOpenAiResponseBody(text) {
const raw = parseJsonOrNull(text);
if (raw) {
return {
raw,
content: extractOpenAiOutputText(raw),
responseId: raw.id ?? null,
model: raw.model ?? null,
usage: raw.usage ?? null,
error: raw.error ?? null
};
}
const stream = parseOpenAiResponsesSse(text);
return {
raw: stream.finalResponse,
content: stream.content,
responseId: stream.responseId,
model: stream.model,
usage: stream.usage,
error: stream.error
};
}
function parseOpenAiResponsesSse(text) {
const deltas = [];
let finalResponse = null;
let responseId = null;
let model = null;
let usage = null;
let error = null;
for (const event of parseSseDataMessages(text)) {
const payload = parseJsonOrNull(event);
if (!payload) continue;
const response = payload.response && typeof payload.response === "object" ? payload.response : null;
if (response) {
finalResponse = response;
responseId = response.id ?? responseId;
model = response.model ?? model;
usage = response.usage ?? usage;
if (response.error) error = response.error;
}
if (payload.response_id) responseId = payload.response_id;
if (payload.item?.id) responseId = responseId ?? payload.item.id;
if (payload.error) error = payload.error;
if (payload.type === "error" && !payload.error) error = payload;
if (typeof payload.delta === "string") deltas.push(payload.delta);
else if (typeof payload.text === "string" && payload.type === "response.output_text.done" && deltas.length === 0) {
deltas.push(payload.text);
}
}
const content = deltas.join("").trim() || extractOpenAiOutputText(finalResponse);
return {
finalResponse,
content,
responseId,
model,
usage,
error
};
}
function parseSseDataMessages(text) {
const messages = [];
for (const block of String(text ?? "").split(/\r?\n\r?\n/u)) {
const data = [];
for (const line of block.split(/\r?\n/u)) {
if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
const message = data.join("\n").trim();
if (message && message !== "[DONE]") messages.push(message);
}
return messages;
}
function parseJsonOrNull(value) {
try {
return JSON.parse(value);
} catch {
return null;
}
}
async function commandExists(command, env) {
if (command.includes("/") || command.includes("\\")) {
try {
await access(command, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
const result = await spawnWithInput("sh", ["-lc", `command -v ${shellQuote(command)}`], "", {
env,
timeoutMs: 3000
});
return result.code === 0;
}
function spawnWithInput(command, args, input, { env, timeoutMs }) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: repoRoot,
env,
stdio: ["pipe", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
child.kill("SIGTERM");
}
}, timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
settled = true;
clearTimeout(timer);
resolve({ code: 127, stdout, stderr: `${stderr}\n${error.message}` });
});
child.on("close", (code) => {
settled = true;
clearTimeout(timer);
resolve({ code: code ?? 1, stdout, stderr });
});
child.stdin.end(input);
});
}
async function makeTempDir() {
const dir = path.join(os.tmpdir(), `hwlab-code-agent-${randomUUID()}`);
await mkdir(dir, { recursive: true });
return dir;
}
function cleanProtocolId(value, prefix) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (/^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/u.test(trimmed)) return trimmed;
return `${prefix}_${trimmed.replace(/[^A-Za-z0-9._:-]/gu, "-").slice(0, 80)}`;
}
function cleanProjectId(value) {
return cleanProtocolId(value, "prj");
}
function nowIso(now) {
return typeof now === "function" ? now() : new Date().toISOString();
}
function effectiveTimeout(timeoutMs) {
return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODE_AGENT_TIMEOUT_MS;
}
function firstNonEmpty(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
function commandLine(command, args) {
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
}
function tailText(value, maxLength = 1200) {
const text = String(value ?? "").trim();
if (text.length <= maxLength) return text;
return text.slice(text.length - maxLength);
}
function redactText(value) {
return String(value ?? "")
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***")
.replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***")
.replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/giu, "$1***")
.replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***");
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}