1710 lines
65 KiB
TypeScript
1710 lines
65 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
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.ts";
|
|
import {
|
|
CODEX_STDIO_BACKEND,
|
|
CODEX_STDIO_CAPABILITY_LEVEL,
|
|
CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
CODEX_STDIO_PROVIDER,
|
|
CODEX_STDIO_RUNNER_KIND,
|
|
CODEX_STDIO_SESSION_MODE,
|
|
createCodexStdioSessionManager,
|
|
longLivedSessionGate as codexLongLivedSessionGate
|
|
} from "./codex-stdio-session.ts";
|
|
import {
|
|
createCodeAgentTraceRecorder,
|
|
defaultCodeAgentTraceStore
|
|
} from "./code-agent-trace-store.ts";
|
|
import {
|
|
DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
|
|
createCodeAgentSessionRegistry
|
|
} from "./code-agent-session-registry.ts";
|
|
import {
|
|
codeAgentSessionLifecycleSummary,
|
|
decorateCodeAgentSession
|
|
} from "./code-agent-session-lifecycle.ts";
|
|
import {
|
|
codeAgentAgentRunAdapterEnabled,
|
|
describeAgentRunAdapterAvailability
|
|
} from "./code-agent-agentrun-adapter.ts";
|
|
const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model;
|
|
const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench";
|
|
const READONLY_RUNNER_PROVIDER = "codex-readonly-runner";
|
|
const READONLY_RUNNER_BACKEND = "hwlab-cloud-api/codex-readonly-runner";
|
|
const READONLY_RUNNER_MODEL = "read-only-tools";
|
|
const READONLY_RUNNER_KIND = "hwlab-readonly-runner";
|
|
const READONLY_RUNNER_SANDBOX = "read-only";
|
|
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
|
|
const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry";
|
|
const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools";
|
|
const HARDWARE_INVOKE_SHELL_METHOD = "hardware.invoke.shell";
|
|
const LEGACY_CODE_AGENT_HARDWARE_PROVIDER = "hwlab-hardware-capability";
|
|
const LEGACY_CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED = "blocked";
|
|
const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback";
|
|
const READONLY_TOOL_OUTPUT_LIMIT = 4000;
|
|
const MAX_READONLY_SESSIONS = 200;
|
|
const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", "");
|
|
const CODEX_STDIO_SOURCE_ISSUE = "pikasTech/HWLAB#275";
|
|
const READONLY_LIMITATION_FLAGS = Object.freeze([
|
|
"not-codex-stdio",
|
|
"not-write-capable",
|
|
"process-local-session-registry"
|
|
]);
|
|
const CODEX_STDIO_LIMITATION_FLAGS = Object.freeze([
|
|
"secret-values-redacted"
|
|
]);
|
|
const CODE_AGENT_SYSTEM_PROMPT = [
|
|
"你是 HWLAB 云工作台的 Code Agent。",
|
|
"请用中文直接回答用户的工作台问题。",
|
|
"当前最小版本可以帮助用户整理任务、查看云工作台资源和说明下一步;不要声称已经执行硬件变更。",
|
|
"不要声称已通过 M3、M4 或 M5 验收。",
|
|
"不要声称 OpenAI Responses 文本 fallback 具备 Codex runner、workspace、tools 或 skills 能力。",
|
|
"不要输出 secret、token、kubeconfig 或环境变量原文。"
|
|
].join("\n");
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const defaultCodeAgentSessionRegistry = createCodeAgentSessionRegistry({
|
|
idleTimeoutMs: DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
|
|
maxSessions: MAX_READONLY_SESSIONS
|
|
});
|
|
const defaultCodexStdioSessionManager = createCodexStdioSessionManager();
|
|
|
|
export async function handleCodeAgentChat(params = {}, options = {}) {
|
|
const timestamp = nowIso(options.now);
|
|
const { conversationId, sessionId, requestedSessionId } = resolveConversationSessionIds(params);
|
|
const messageId = `msg_${randomUUID()}`;
|
|
const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`;
|
|
const providerPlan = resolveProviderPlan(options.env ?? process.env, options);
|
|
const sessionRegistry = resolveCodeAgentSessionRegistry(options);
|
|
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
|
const traceRecorder = createCodeAgentTraceRecorder({
|
|
traceStore,
|
|
traceId,
|
|
now: options.now,
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
workspace: options.workspace,
|
|
sandbox: "workspace-write",
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE
|
|
});
|
|
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 runnerIntent = {
|
|
kind: "none",
|
|
toolName: "codex-stdio.session"
|
|
};
|
|
traceRecorder.append({
|
|
type: "request",
|
|
status: "accepted",
|
|
label: "request:accepted",
|
|
promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message,
|
|
waitingFor: "codex-stdio-readiness"
|
|
});
|
|
|
|
const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options);
|
|
if (codexStdioLongLivedReady(codexStdioAvailability)) {
|
|
const stdioResult = await callCodexStdioRunner({
|
|
message,
|
|
conversationId,
|
|
sessionId: requestedSessionId,
|
|
threadId: params.threadId,
|
|
traceId,
|
|
env: options.env ?? process.env,
|
|
now: options.now,
|
|
workspace: options.workspace,
|
|
timeoutMs: options.timeoutMs,
|
|
hardTimeoutMs: options.hardTimeoutMs,
|
|
model: providerPlan.model,
|
|
codexStdioManager: options.codexStdioManager,
|
|
skillsDirs: options.skillsDirs,
|
|
skillsDirsExact: options.skillsDirsExact,
|
|
traceRecorder,
|
|
conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId),
|
|
externalNetworkIntent: null
|
|
});
|
|
return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry });
|
|
}
|
|
|
|
const primaryBlocker = codexStdioAvailability.blockers?.[0] ?? null;
|
|
traceRecorder.append({
|
|
type: "error",
|
|
status: "blocked",
|
|
label: "codex-stdio:blocked",
|
|
errorCode: primaryBlocker?.code ?? "codex_stdio_blocked",
|
|
message: primaryBlocker?.summary ?? "Codex stdio is not available; no local fallback will be used.",
|
|
waitingFor: "codex-stdio-readiness",
|
|
terminal: true
|
|
});
|
|
throw runnerError(primaryBlocker?.code ?? "codex_stdio_blocked", "Codex stdio long-lived session is unavailable; no controlled-readonly, skills, shell, or text fallback will answer this request.", {
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
model: providerPlan.model,
|
|
backend: CODEX_STDIO_BACKEND,
|
|
workspace: codexStdioAvailability.workspace ?? options.workspace ?? repoRoot,
|
|
sandbox: codexStdioAvailability.sandbox ?? "workspace-write",
|
|
session: null,
|
|
toolCalls: [],
|
|
skills: notRequestedSkills(),
|
|
runner: codexStdioBlockedRunnerDescriptor({ workspace: codexStdioAvailability.workspace ?? options.workspace, session: null }),
|
|
runnerTrace: traceRecorder.runnerTrace({
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
workspace: codexStdioAvailability.workspace ?? options.workspace ?? repoRoot,
|
|
sandbox: codexStdioAvailability.sandbox ?? "workspace-write",
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
limitations: ["no-fallback", "codex-stdio-required"],
|
|
outputTruncated: false
|
|
}),
|
|
capabilityLevel: "blocked",
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
sessionReuse: null,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
runnerLimitations: ["codex-stdio-required", "no-controlled-readonly-fallback", "no-text-fallback"],
|
|
codexStdioFeasibility: codexStdioAvailability,
|
|
longLivedSessionGate: longLivedSessionGate({
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
session: null,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
codexStdioFeasibility: codexStdioAvailability
|
|
}),
|
|
blockers: codexStdioAvailability.blockers?.length
|
|
? codexStdioAvailability.blockers
|
|
: [{
|
|
code: "codex_stdio_blocked",
|
|
sourceIssue: "pikasTech/HWLAB#275",
|
|
summary: "Codex stdio readiness did not pass."
|
|
}],
|
|
route: "/v1/agent/chat",
|
|
toolName: runnerIntent.toolName ?? "codex-stdio.session",
|
|
availability: await describeCodeAgentAvailability(options.env ?? process.env, options)
|
|
});
|
|
} catch (error) {
|
|
const failedAt = nowIso(options.now);
|
|
const payload = {
|
|
...base,
|
|
status: chatStatusForError(error),
|
|
updatedAt: failedAt,
|
|
error: normalizeChatError(error, {
|
|
traceId,
|
|
provider: error.provider ?? base.provider,
|
|
model: error.model ?? base.model,
|
|
backend: error.backend ?? base.backend,
|
|
runner: error.runner,
|
|
capabilityLevel: error.capabilityLevel ?? "blocked",
|
|
route: error.route,
|
|
toolName: error.toolName
|
|
})
|
|
};
|
|
if (payload.error?.diagnosis) payload.diagnosis = payload.error.diagnosis;
|
|
if (payload.error?.blocker) payload.blocker = payload.error.blocker;
|
|
if (payload.error?.blockers) payload.blockers = payload.error.blockers;
|
|
if (error.provider) payload.provider = error.provider;
|
|
if (error.model) payload.model = error.model;
|
|
if (error.backend) payload.backend = error.backend;
|
|
if (error.workspace !== undefined) payload.workspace = error.workspace;
|
|
if (error.sandbox !== undefined) payload.sandbox = error.sandbox;
|
|
if (error.session !== undefined) payload.session = error.session;
|
|
if (error.toolCalls !== undefined) payload.toolCalls = error.toolCalls;
|
|
if (error.skills !== undefined) payload.skills = error.skills;
|
|
if (error.runner !== undefined) payload.runner = error.runner;
|
|
if (error.runnerTrace !== undefined) payload.runnerTrace = error.runnerTrace;
|
|
if (error.providerTrace !== undefined) payload.providerTrace = error.providerTrace;
|
|
if (payload.runnerTrace === undefined) {
|
|
payload.runnerTrace = traceRecorder.runnerTrace({
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
workspace: error.workspace ?? options.workspace ?? repoRoot,
|
|
sandbox: error.sandbox ?? "workspace-write",
|
|
sessionMode: error.sessionMode ?? CODEX_STDIO_SESSION_MODE,
|
|
implementationType: error.implementationType ?? CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
limitations: error.runnerLimitations ?? ["codex-stdio-required"]
|
|
});
|
|
}
|
|
if (error.capabilityLevel !== undefined) payload.capabilityLevel = error.capabilityLevel;
|
|
if (error.sessionMode !== undefined) payload.sessionMode = error.sessionMode;
|
|
if (error.sessionReuse !== undefined) payload.sessionReuse = error.sessionReuse;
|
|
if (error.implementationType !== undefined) payload.implementationType = error.implementationType;
|
|
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
|
|
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
|
|
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
|
|
const factPayload = shouldRecordErrorConversationFact(payload) ? attachConversationFacts(decorateChatSessionLifecycle(payload), sessionRegistry, { now: options.now }) : null;
|
|
if (factPayload?.conversationFacts) {
|
|
payload.conversationFacts = factPayload.conversationFacts;
|
|
} else if (error.conversationFacts !== undefined) {
|
|
payload.conversationFacts = error.conversationFacts;
|
|
} else if (typeof sessionRegistry.getConversationFacts === "function") {
|
|
payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId);
|
|
}
|
|
if (payload.conversationFacts && payload.error?.blocker) payload.error.blocker.conversationFacts = payload.conversationFacts;
|
|
if (payload.conversationFacts && payload.blocker) payload.blocker.conversationFacts = payload.conversationFacts;
|
|
if (error.availability !== undefined) {
|
|
payload.availability = error.availability;
|
|
} else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) {
|
|
payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
|
} else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed"].includes(error.code)) {
|
|
payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
|
}
|
|
return decorateChatSessionLifecycle(payload);
|
|
}
|
|
}
|
|
|
|
function shouldRecordErrorConversationFact(payload = {}) {
|
|
if (!payload || typeof payload !== "object") return false;
|
|
if (payload.status === "running" || payload.status === "completed") return false;
|
|
if (payload.partialContext) return true;
|
|
if (payload.runnerTrace?.partialContext) return true;
|
|
return Boolean(payload.runnerTrace?.traceId && payload.sessionId && payload.conversationId);
|
|
}
|
|
|
|
function completedRunnerPayload({ base, runnerResult, messageId, now, sessionRegistry }) {
|
|
const completedAt = nowIso(now);
|
|
const blocker = structuredCompletionBlocker(runnerResult, {
|
|
traceId: base.traceId,
|
|
provider: runnerResult.provider,
|
|
backend: runnerResult.backend,
|
|
runner: runnerResult.runner,
|
|
capabilityLevel: runnerResult.capabilityLevel
|
|
});
|
|
const payload = finalizeCodeAgentChatPayload(decorateChatSessionLifecycle({
|
|
...base,
|
|
sessionId: runnerResult.session?.sessionId ?? base.sessionId,
|
|
status: "completed",
|
|
updatedAt: completedAt,
|
|
provider: runnerResult.provider,
|
|
model: runnerResult.model,
|
|
backend: runnerResult.backend,
|
|
workspace: runnerResult.workspace,
|
|
sandbox: runnerResult.sandbox,
|
|
session: runnerResult.session,
|
|
sessionMode: runnerResult.sessionMode,
|
|
sessionReuse: runnerResult.sessionReuse,
|
|
implementationType: runnerResult.implementationType,
|
|
runnerLimitations: runnerResult.runnerLimitations,
|
|
codexStdioFeasibility: runnerResult.codexStdioFeasibility,
|
|
longLivedSessionGate: runnerResult.longLivedSessionGate,
|
|
toolCalls: runnerResult.toolCalls,
|
|
skills: runnerResult.skills,
|
|
runner: runnerResult.runner,
|
|
runnerTrace: runnerResult.runnerTrace,
|
|
capabilityLevel: runnerResult.capabilityLevel,
|
|
...(runnerResult.responseType ? { responseType: runnerResult.responseType } : {}),
|
|
...(runnerResult.m3Io ? { m3Io: runnerResult.m3Io } : {}),
|
|
...(runnerResult.hardware ? { hardware: runnerResult.hardware } : {}),
|
|
...(runnerResult.operationId !== undefined ? { operationId: runnerResult.operationId } : {}),
|
|
...(runnerResult.dispatchStatus !== undefined ? { dispatchStatus: runnerResult.dispatchStatus } : {}),
|
|
...(runnerResult.exitCode !== undefined ? { exitCode: runnerResult.exitCode } : {}),
|
|
...(runnerResult.stdoutSummary !== undefined ? { stdoutSummary: runnerResult.stdoutSummary } : {}),
|
|
...(runnerResult.stderrSummary !== undefined ? { stderrSummary: runnerResult.stderrSummary } : {}),
|
|
...(runnerResult.route !== undefined ? { route: runnerResult.route } : {}),
|
|
...(runnerResult.toolName !== undefined ? { toolName: runnerResult.toolName } : {}),
|
|
reply: {
|
|
messageId,
|
|
role: "assistant",
|
|
content: runnerResult.content,
|
|
createdAt: completedAt
|
|
},
|
|
...(blocker ? { blocker, blockers: [blocker, ...(runnerResult.blockers ?? [])].filter(Boolean) } : {}),
|
|
usage: null,
|
|
providerTrace: runnerResult.providerTrace
|
|
}));
|
|
return decorateChatSessionLifecycle(attachConversationFacts(payload, sessionRegistry, { now }));
|
|
}
|
|
|
|
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", "timeout", "error", "canceled"].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 (["failed", "timeout", "error", "canceled"].includes(payload.status) && typeof payload.error?.message !== "string") {
|
|
throw new Error("non-completed 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");
|
|
}
|
|
validateCodeAgentToolCallContract(payload);
|
|
}
|
|
|
|
function validateCodeAgentToolCallContract(payload) {
|
|
if (!Array.isArray(payload.toolCalls)) return;
|
|
for (const [index, toolCall] of payload.toolCalls.entries()) {
|
|
if (!toolCall || typeof toolCall !== "object") {
|
|
throw new Error(`code agent toolCalls[${index}] must be an object`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function finalizeCodeAgentChatPayload(payload) {
|
|
validateCodeAgentChatSchema(payload);
|
|
return payload;
|
|
}
|
|
|
|
function attachConversationFacts(payload, sessionRegistry, { now } = {}) {
|
|
if (!sessionRegistry || typeof sessionRegistry.recordFact !== "function") return payload;
|
|
const conversationFacts = sessionRegistry.recordFact(payload.conversationId, {
|
|
kind: payload.toolCalls?.some((toolCall) => toolCall?.name === "session.context") ? "session_context" : "runner_turn",
|
|
conversationId: payload.conversationId,
|
|
sessionId: payload.sessionId,
|
|
traceId: payload.traceId,
|
|
provider: payload.provider,
|
|
backend: payload.backend,
|
|
workspace: payload.workspace,
|
|
sandbox: payload.sandbox,
|
|
session: payload.session,
|
|
sessionMode: payload.sessionMode,
|
|
sessionReuse: payload.sessionReuse,
|
|
implementationType: payload.implementationType,
|
|
runner: payload.runner,
|
|
runnerTrace: payload.runnerTrace,
|
|
capabilityLevel: payload.capabilityLevel,
|
|
toolCalls: payload.toolCalls,
|
|
skills: payload.skills
|
|
}, { now });
|
|
return {
|
|
...payload,
|
|
conversationFacts
|
|
};
|
|
}
|
|
|
|
function decorateChatSessionLifecycle(payload) {
|
|
if (!payload || typeof payload !== "object") return payload;
|
|
const decoratedSession = payload.session && typeof payload.session === "object"
|
|
? decorateCodeAgentSession(payload.session, {
|
|
reused: payload.sessionReuse?.reused ?? payload.session?.reused,
|
|
runnerTrace: payload.runnerTrace,
|
|
sessionReuse: payload.sessionReuse,
|
|
status: payload.status,
|
|
provider: payload.provider,
|
|
runner: payload.runner,
|
|
capabilityLevel: payload.capabilityLevel,
|
|
implementationType: payload.implementationType,
|
|
longLivedSessionGate: payload.longLivedSessionGate,
|
|
error: payload.error,
|
|
blocker: payload.blocker
|
|
})
|
|
: payload.session;
|
|
const runnerTrace = payload.runnerTrace && typeof payload.runnerTrace === "object"
|
|
? {
|
|
...payload.runnerTrace,
|
|
sessionLifecycleStatus: payload.runnerTrace.sessionLifecycleStatus ??
|
|
codeAgentSessionLifecycleSummary({
|
|
session: decoratedSession,
|
|
sessionStatus: payload.runnerTrace.sessionStatus,
|
|
status: payload.status,
|
|
provider: payload.provider,
|
|
runner: payload.runner,
|
|
capabilityLevel: payload.capabilityLevel,
|
|
implementationType: payload.implementationType,
|
|
error: payload.error,
|
|
blocker: payload.blocker
|
|
}).status
|
|
}
|
|
: payload.runnerTrace;
|
|
const summary = codeAgentSessionLifecycleSummary({
|
|
...payload,
|
|
session: decoratedSession,
|
|
runnerTrace
|
|
});
|
|
return {
|
|
...payload,
|
|
session: decoratedSession,
|
|
runnerTrace,
|
|
sessionStatus: decoratedSession?.status ?? payload.sessionStatus ?? runnerTrace?.sessionStatus ?? null,
|
|
sessionLifecycleStatus: summary.status,
|
|
sessionLifecycle: summary,
|
|
sessionSummary: summary,
|
|
sessionReused: summary.reused,
|
|
isNewSession: summary.newSession,
|
|
degraded: summary.degraded,
|
|
degradationReason: summary.reason,
|
|
degradationReasonCode: summary.reasonCode
|
|
};
|
|
}
|
|
|
|
export async function describeCodeAgentAvailability(env = process.env, options = {}) {
|
|
if (codeAgentAgentRunAdapterEnabled(env)) {
|
|
return describeAgentRunAdapterAvailability(env, options);
|
|
}
|
|
|
|
const providerPlan = resolveProviderPlan(env, options);
|
|
const providerContract = inspectCodeAgentProviderEnv(env);
|
|
const missingEnv = providerPlan.mode === "openai"
|
|
? providerContract.missingEnv
|
|
: [];
|
|
|
|
if (providerPlan.mode === "codex-cli" && !env.OPENAI_API_KEY) {
|
|
missingEnv.push("OPENAI_API_KEY");
|
|
}
|
|
const sessionRegistry = resolveCodeAgentSessionRegistry(options);
|
|
const runnerAvailability = await inspectReadOnlyRunnerAvailability(env, {
|
|
...options,
|
|
sessionRegistry
|
|
});
|
|
const codexStdio = await inspectCodexStdioFeasibility(env, options);
|
|
const codexStdioReady = codexStdioLongLivedReady(codexStdio);
|
|
const codexStdioActive = providerPlan.mode !== "openai" && codexStdioReady;
|
|
const activeRunnerAvailability = codexStdioActive
|
|
? codexStdioRunnerAvailability(codexStdio)
|
|
: codexStdioBlockedRunnerAvailability(codexStdio);
|
|
const blocked = providerPlan.mode === "openai"
|
|
? !providerContract.ready
|
|
: providerPlan.mode === "codex-stdio"
|
|
? !codexStdioReady
|
|
: missingEnv.length > 0;
|
|
const fallbackAvailability = {
|
|
provider: providerPlan.provider,
|
|
model: providerPlan.model,
|
|
backend: providerPlan.backend,
|
|
mode: providerPlan.mode,
|
|
status: blocked ? "blocked" : "available",
|
|
ready: !blocked,
|
|
capabilityLevel: blocked ? "blocked" : "text-chat-only",
|
|
blocker: blocked ? fallbackBlockerForPlan({ providerPlan, providerContract, codexStdio }) : null,
|
|
reason: blocked ? "provider_unavailable" : null,
|
|
missingEnv,
|
|
secretRefs: blocked ? providerContract.secretRefs : [],
|
|
egress: providerContract.egress,
|
|
safety: providerContract.safety
|
|
};
|
|
const longLivedGate = longLivedSessionGate({
|
|
provider: codexStdioActive ? CODEX_STDIO_PROVIDER : runnerAvailability.provider,
|
|
runnerKind: codexStdioActive ? CODEX_STDIO_RUNNER_KIND : runnerAvailability.kind,
|
|
sessionMode: codexStdioActive ? CODEX_STDIO_SESSION_MODE : READONLY_SESSION_MODE,
|
|
implementationType: codexStdioActive ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE,
|
|
codexStdioFeasibility: codexStdio
|
|
});
|
|
const agentKind = codexStdioActive ? "codex-stdio-feasible" : "codex-stdio-blocked";
|
|
const capabilityStatus = codexStdioActive ? "codex-stdio-feasible" : "blocked";
|
|
const codeAgentReady = codexStdioActive;
|
|
const status = codexStdioActive ? "codex-stdio-feasible" : "blocked";
|
|
const capabilityLevel = codexStdioActive ? "long-lived-codex-stdio-session" : "blocked";
|
|
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",
|
|
"runner",
|
|
"workspace",
|
|
"sandbox",
|
|
"toolCalls",
|
|
"skills",
|
|
"runnerTrace",
|
|
"capabilityLevel",
|
|
"session",
|
|
"sessionMode",
|
|
"sessionReuse",
|
|
"implementationType",
|
|
"runnerLimitations",
|
|
"codexStdioFeasibility",
|
|
"longLivedSessionGate",
|
|
"error.message",
|
|
"error.code",
|
|
"error.missingEnv",
|
|
"availability.status"
|
|
],
|
|
runner: activeRunnerAvailability,
|
|
fallback: fallbackAvailability,
|
|
codexStdio,
|
|
status,
|
|
readinessStatus: status,
|
|
agentKind,
|
|
capabilityStatus,
|
|
blocker: codeAgentReady ? null : primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }),
|
|
reason: codeAgentReady ? null : primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }),
|
|
summary: codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }),
|
|
m3IoSkill: runnerAvailability.m3IoSkill,
|
|
missingEnv,
|
|
secretRefs: blocked ? providerContract.secretRefs : [],
|
|
egress: providerContract.egress,
|
|
safety: providerContract.safety,
|
|
capabilityLevel,
|
|
workspace: activeRunnerAvailability.workspace,
|
|
sandbox: activeRunnerAvailability.sandbox,
|
|
session: activeRunnerAvailability.sessionRegistry?.recentSessions?.[0] ?? null,
|
|
sessionStatus: activeRunnerAvailability.sessionRegistry?.recentSessions?.[0]?.status ?? null,
|
|
sessionRegistry: activeRunnerAvailability.sessionRegistry ?? runnerAvailability.sessionRegistry,
|
|
sessionMode: activeRunnerAvailability.sessionMode,
|
|
implementationType: activeRunnerAvailability.implementationType,
|
|
runnerLimitations: activeRunnerAvailability.runnerLimitations,
|
|
codexStdioFeasibility: codexStdio,
|
|
longLivedSessionGate: longLivedGate,
|
|
blockers: [
|
|
...codexStdio.blockers,
|
|
...(blocked && providerPlan.mode !== "codex-stdio" ? [{
|
|
code: "openai_fallback_blocked",
|
|
sourceIssue: "pikasTech/HWLAB#143",
|
|
summary: providerPlan.mode === "openai" ? providerContract.blocker : "Provider fallback credential/config is incomplete."
|
|
}] : [])
|
|
],
|
|
blockerCodes: [
|
|
...codexStdio.blockerCodes,
|
|
...(blocked && providerPlan.mode !== "codex-stdio" ? ["openai_fallback_blocked"] : [])
|
|
],
|
|
ready: codeAgentReady,
|
|
partialReady: false
|
|
};
|
|
}
|
|
|
|
function fallbackBlockerForPlan({ providerPlan, providerContract, codexStdio }) {
|
|
if (providerPlan.mode === "openai") return providerContract.blocker;
|
|
if (providerPlan.mode === "codex-stdio") {
|
|
return codexStdio.blockers?.[0]?.summary ?? "Codex stdio long-lived session is blocked.";
|
|
}
|
|
return "凭证缺口";
|
|
}
|
|
|
|
function codexStdioLongLivedReady(codexStdio = {}) {
|
|
return Boolean(
|
|
(codexStdio.ready === true || codexStdio.canStartLongLivedCodexStdio === true) &&
|
|
codexStdio.commandProbe?.ready === true
|
|
);
|
|
}
|
|
|
|
function codexStdioRunnerAvailability(codexStdio = {}) {
|
|
const workspace = codexStdio.workspace ?? repoRoot;
|
|
const sandbox = codexStdio.sandbox ?? "workspace-write";
|
|
return {
|
|
kind: CODEX_STDIO_RUNNER_KIND,
|
|
backend: CODEX_STDIO_BACKEND,
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
workspace,
|
|
sandbox,
|
|
mode: CODEX_STDIO_SESSION_MODE,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
session: CODEX_STDIO_SESSION_MODE,
|
|
status: "available",
|
|
ready: true,
|
|
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
longLivedSession: true,
|
|
durableSession: true,
|
|
durable: true,
|
|
codexStdio: true,
|
|
writeCapable: true,
|
|
readOnly: false,
|
|
runnerLimitations: [...CODEX_STDIO_LIMITATION_FLAGS],
|
|
codexStdioFeasibility: codexStdio,
|
|
longLivedSessionGate: longLivedSessionGate({
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
codexStdioFeasibility: codexStdio
|
|
}),
|
|
sessionRegistry: codexStdioSessionRegistrySummary(codexStdio),
|
|
safety: {
|
|
secretsRead: false,
|
|
secretValuesPrinted: false,
|
|
kubeconfigRead: false,
|
|
hardwareControlViaCloudApiOnly: false,
|
|
directGatewayCallsAllowed: true,
|
|
directBoxSimuCallsAllowed: true,
|
|
directPatchPanelCallsAllowed: true,
|
|
m3m4m5AcceptanceClaimsAllowed: false
|
|
}
|
|
};
|
|
}
|
|
|
|
function codexStdioBlockedRunnerAvailability(codexStdio = {}) {
|
|
const workspace = codexStdio.workspace ?? repoRoot;
|
|
const sandbox = codexStdio.sandbox ?? "workspace-write";
|
|
return {
|
|
kind: CODEX_STDIO_RUNNER_KIND,
|
|
backend: CODEX_STDIO_BACKEND,
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
workspace,
|
|
sandbox,
|
|
mode: CODEX_STDIO_SESSION_MODE,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
session: CODEX_STDIO_SESSION_MODE,
|
|
status: "blocked",
|
|
ready: false,
|
|
capabilityLevel: "blocked",
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
longLivedSession: false,
|
|
durableSession: false,
|
|
durable: false,
|
|
codexStdio: true,
|
|
writeCapable: false,
|
|
readOnly: false,
|
|
runnerLimitations: ["codex-stdio-required", "no-controlled-readonly-fallback", "no-text-fallback"],
|
|
codexStdioFeasibility: codexStdio,
|
|
longLivedSessionGate: longLivedSessionGate({
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
codexStdioFeasibility: codexStdio
|
|
}),
|
|
sessionRegistry: codexStdioSessionRegistrySummary(codexStdio),
|
|
safety: {
|
|
secretsRead: false,
|
|
secretValuesPrinted: false,
|
|
kubeconfigRead: false,
|
|
hardwareControlViaCloudApiOnly: false,
|
|
directGatewayCallsAllowed: true,
|
|
directBoxSimuCallsAllowed: true,
|
|
directPatchPanelCallsAllowed: true,
|
|
m3m4m5AcceptanceClaimsAllowed: false
|
|
}
|
|
};
|
|
}
|
|
|
|
function codexStdioSessionRegistrySummary(codexStdio = {}) {
|
|
return {
|
|
kind: "codex-stdio-session-registry",
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
status: "available",
|
|
sessionCount: codexStdio.sessionLifecycle?.activeSessions ?? codexStdio.lifecycleSupervisor?.activeSessions ?? null,
|
|
maxSessions: codexStdio.sessionLifecycle?.maxSessions ?? codexStdio.lifecycleSupervisor?.maxSessions ?? null,
|
|
idleTimeoutMs: codexStdio.sessionLifecycle?.idleTimeoutMs ?? codexStdio.lifecycleSupervisor?.idleTimeoutMs ?? null,
|
|
statuses: ["creating", "ready", "busy", "idle", "timeout", "error", "canceled", "interrupted", "expired", "failed"],
|
|
statusCounts: codexStdio.statusCounts ?? null,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
recentSessions: Array.isArray(codexStdio.recentSessions) ? codexStdio.recentSessions : [],
|
|
longLivedCodexStdio: true,
|
|
longLivedSession: true,
|
|
durable: true,
|
|
codexStdio: true,
|
|
secretMaterialStored: false,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }) {
|
|
if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].summary;
|
|
if (blocked && !runnerAvailability.ready) return providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口";
|
|
if (runnerAvailability.ready) return "Codex stdio is blocked; controlled read-only runner fallback is disabled for /v1/agent/chat.";
|
|
return null;
|
|
}
|
|
|
|
function primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }) {
|
|
if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].code;
|
|
if (blocked && !runnerAvailability.ready) return "provider_unavailable";
|
|
if (runnerAvailability.ready) return "codex_stdio_blocked_fallback_disabled";
|
|
return null;
|
|
}
|
|
|
|
function codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }) {
|
|
if (codexStdio.ready) {
|
|
return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat sends natural-language requests to the persistent Codex session with full workspace and tool access.";
|
|
}
|
|
if (runnerAvailability.ready) return "Codex stdio long-lived session is blocked; /v1/agent/chat will not use controlled-readonly-session-registry, local skills discovery, shell/file shortcuts, or text fallback.";
|
|
return blocked
|
|
? "OpenAI fallback 和 Codex stdio long-lived session 都 blocked;不返回伪完成。"
|
|
: "Only text-chat fallback is available; it does not satisfy the Codex stdio session gate.";
|
|
}
|
|
|
|
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"
|
|
};
|
|
}
|
|
if (provider === "codex-stdio") {
|
|
return {
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
model,
|
|
backend: CODEX_STDIO_BACKEND,
|
|
mode: "codex-stdio"
|
|
};
|
|
}
|
|
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 inspectReadOnlyRunnerAvailability(env, options = {}) {
|
|
const workspace = resolveRunnerWorkspace(env, options);
|
|
const workspaceReady = Boolean(workspace && existsSync(workspace));
|
|
const skillsDirs = resolveSkillDirs(env, options);
|
|
const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir));
|
|
const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, options);
|
|
const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe();
|
|
return {
|
|
kind: READONLY_RUNNER_KIND,
|
|
backend: READONLY_RUNNER_BACKEND,
|
|
provider: READONLY_RUNNER_PROVIDER,
|
|
workspace,
|
|
sandbox: READONLY_RUNNER_SANDBOX,
|
|
mode: READONLY_SESSION_MODE,
|
|
sessionMode: READONLY_SESSION_MODE,
|
|
session: READONLY_SESSION_MODE,
|
|
status: workspaceReady ? "available" : "blocked",
|
|
ready: workspaceReady,
|
|
capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked",
|
|
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
|
longLivedSession: true,
|
|
durableSession: false,
|
|
codexStdio: false,
|
|
writeCapable: false,
|
|
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
|
codexStdioFeasibility,
|
|
longLivedSessionGate: longLivedSessionGate({
|
|
provider: READONLY_RUNNER_PROVIDER,
|
|
runnerKind: READONLY_RUNNER_KIND,
|
|
sessionMode: READONLY_SESSION_MODE,
|
|
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
|
codexStdioFeasibility
|
|
}),
|
|
sessionRegistry,
|
|
skillsDirs,
|
|
skillsDirsPresent,
|
|
safety: runnerSafetyContract()
|
|
};
|
|
}
|
|
|
|
function structuredCompletionBlocker(result, context = {}) {
|
|
if (!result || typeof result !== "object") return null;
|
|
if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") {
|
|
return structuredBlocker({
|
|
code: "text_chat_only_fallback",
|
|
layer: "provider",
|
|
message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.",
|
|
userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。",
|
|
retryable: false,
|
|
traceId: context.traceId,
|
|
provider: result.provider ?? context.provider,
|
|
backend: result.backend ?? context.backend,
|
|
runner: result.runner ?? context.runner,
|
|
capabilityLevel: result.capabilityLevel ?? context.capabilityLevel,
|
|
blockers: result.longLivedSessionGate?.blockers
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function notRequestedSkills() {
|
|
return {
|
|
status: "not_requested",
|
|
items: [],
|
|
count: 0,
|
|
blockers: []
|
|
};
|
|
}
|
|
|
|
async function callCodexStdioRunner({ message, conversationId, sessionId, threadId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, skillsDirs, skillsDirsExact, traceRecorder, conversationFacts, externalNetworkIntent = null }) {
|
|
const manager = resolveCodexStdioSessionManager({ codexStdioManager });
|
|
try {
|
|
return await manager.chat({
|
|
message,
|
|
conversationId,
|
|
sessionId,
|
|
threadId,
|
|
traceId,
|
|
env,
|
|
now,
|
|
workspace,
|
|
timeoutMs,
|
|
hardTimeoutMs,
|
|
model,
|
|
skillsDirs,
|
|
skillsDirsExact,
|
|
traceRecorder,
|
|
conversationFacts,
|
|
externalNetworkIntent
|
|
});
|
|
} catch (error) {
|
|
const availability = error.availability ?? manager.describe({ env, workspace });
|
|
const session = error.session ?? null;
|
|
const trace = error.runnerTrace ?? {
|
|
traceId,
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
workspace: workspace ?? availability.workspace ?? repoRoot,
|
|
sandbox: availability.sandbox ?? "workspace-write",
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
events: [`blocked:${error.code ?? "codex_stdio_failed"}`],
|
|
valuesPrinted: false
|
|
};
|
|
throw runnerError(error.code ?? "codex_stdio_failed", error.message, {
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
model,
|
|
backend: CODEX_STDIO_BACKEND,
|
|
workspace: workspace ?? availability.workspace ?? null,
|
|
sandbox: availability.sandbox ?? "workspace-write",
|
|
session,
|
|
toolCalls: Array.isArray(error.toolCalls) ? error.toolCalls : [],
|
|
skills: error.skills ?? notRequestedSkills(),
|
|
runner: {
|
|
kind: CODEX_STDIO_RUNNER_KIND,
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
backend: CODEX_STDIO_BACKEND,
|
|
workspace: workspace ?? availability.workspace ?? repoRoot,
|
|
sandbox: availability.sandbox ?? "workspace-write",
|
|
session: CODEX_STDIO_SESSION_MODE,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
sessionId: session?.sessionId ?? null,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
codexStdio: true,
|
|
longLivedSession: true,
|
|
durableSession: true,
|
|
writeCapable: true,
|
|
readOnly: false,
|
|
capabilityLevel: "blocked"
|
|
},
|
|
runnerTrace: trace,
|
|
capabilityLevel: "blocked",
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
sessionReuse: session ? sessionReuseEvidence(session) : null,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
runnerLimitations: error.runnerLimitations ?? ["codex-stdio-blocked"],
|
|
codexStdioFeasibility: availability,
|
|
longLivedSessionGate: longLivedSessionGate({
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
|
session,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
codexStdioFeasibility: availability
|
|
}),
|
|
blockers: error.blockers ?? availability.blockers,
|
|
route: error.route ?? null,
|
|
toolName: error.toolName ?? externalNetworkIntent?.toolName ?? (error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session"),
|
|
conversationId,
|
|
targetUrl: error.targetUrl,
|
|
timeoutMs: error.timeoutMs,
|
|
hardTimeoutMs: error.hardTimeoutMs,
|
|
idleMs: error.idleMs,
|
|
lastActivityAt: error.lastActivityAt,
|
|
waitingFor: error.waitingFor,
|
|
diagnosis: error.diagnosis,
|
|
providerTrace: error.providerTrace,
|
|
stage: error.stage,
|
|
missingTools: error.missingTools,
|
|
availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace })
|
|
});
|
|
}
|
|
}
|
|
|
|
function sessionReuseEvidence(session) {
|
|
return {
|
|
conversationId: session.conversationId,
|
|
sessionId: session.sessionId,
|
|
mapped: true,
|
|
reused: session.reused,
|
|
turn: session.turn,
|
|
previousTurns: Math.max(0, session.turn - 1),
|
|
workspace: session.workspace,
|
|
createdAt: session.createdAt,
|
|
updatedAt: session.updatedAt,
|
|
idleTimeoutMs: session.idleTimeoutMs,
|
|
expiresAt: session.expiresAt,
|
|
lastTraceId: session.lastTraceId,
|
|
status: session.status
|
|
};
|
|
}
|
|
|
|
function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) {
|
|
return registry.release(session.sessionId, {
|
|
now,
|
|
traceId,
|
|
conversationId,
|
|
reused: session.reused,
|
|
status: "idle"
|
|
}) ?? session;
|
|
}
|
|
|
|
function resolveCodeAgentSessionRegistry(options = {}) {
|
|
return options.sessionRegistry &&
|
|
typeof options.sessionRegistry.acquire === "function" &&
|
|
typeof options.sessionRegistry.release === "function" &&
|
|
typeof options.sessionRegistry.describe === "function"
|
|
? options.sessionRegistry
|
|
: defaultCodeAgentSessionRegistry;
|
|
}
|
|
|
|
function resolveCodexStdioSessionManager(options = {}) {
|
|
return options.codexStdioManager &&
|
|
typeof options.codexStdioManager.describe === "function" &&
|
|
typeof options.codexStdioManager.chat === "function" &&
|
|
typeof options.codexStdioManager.cancel === "function" &&
|
|
typeof options.codexStdioManager.reapIdle === "function"
|
|
? options.codexStdioManager
|
|
: defaultCodexStdioSessionManager;
|
|
}
|
|
|
|
function runnerSafetyContract() {
|
|
return {
|
|
secretsRead: false,
|
|
secretValuesPrinted: false,
|
|
kubeconfigRead: false,
|
|
hardwareWritesAllowed: true,
|
|
directGatewayCallsAllowed: true,
|
|
directBoxSimuCallsAllowed: true,
|
|
directPatchPanelCallsAllowed: true,
|
|
openAiHardwareFallbackAllowed: false,
|
|
m3m4m5AcceptanceClaimsAllowed: false,
|
|
outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT
|
|
};
|
|
}
|
|
|
|
async function inspectCodexStdioFeasibility(env = process.env, options = {}) {
|
|
const manager = resolveCodexStdioSessionManager(options);
|
|
const descriptor = {
|
|
env,
|
|
workspace: options.workspace,
|
|
command: options.command,
|
|
sandbox: options.sandbox
|
|
};
|
|
const feasibility = typeof manager.probe === "function"
|
|
? await manager.probe(descriptor)
|
|
: manager.describe(descriptor);
|
|
return {
|
|
...feasibility,
|
|
checked: true,
|
|
implementationRequired: "codex-stdio-or-equivalent-long-lived-runner",
|
|
currentImplementation: feasibility.ready ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE,
|
|
sourceIssue: CODEX_STDIO_SOURCE_ISSUE
|
|
};
|
|
}
|
|
|
|
function longLivedSessionGate({
|
|
provider,
|
|
runnerKind,
|
|
session,
|
|
sessionMode,
|
|
implementationType,
|
|
codexStdioFeasibility
|
|
} = {}) {
|
|
const normalizedProvider = String(provider ?? "").trim();
|
|
const normalizedRunnerKind = String(runnerKind ?? "").trim();
|
|
const normalizedSessionMode = String(sessionMode ?? "").trim();
|
|
const normalizedImplementation = String(implementationType ?? "").trim();
|
|
return codexLongLivedSessionGate({
|
|
provider: normalizedProvider,
|
|
runnerKind: normalizedRunnerKind,
|
|
session,
|
|
sessionMode: normalizedSessionMode,
|
|
implementationType: normalizedImplementation,
|
|
codexStdioFeasibility
|
|
});
|
|
}
|
|
|
|
function codexStdioBlockedRunnerDescriptor({ workspace, session = null } = {}) {
|
|
return {
|
|
kind: CODEX_STDIO_RUNNER_KIND,
|
|
provider: CODEX_STDIO_PROVIDER,
|
|
backend: CODEX_STDIO_BACKEND,
|
|
workspace: workspace ?? repoRoot,
|
|
sandbox: "workspace-write",
|
|
session: CODEX_STDIO_SESSION_MODE,
|
|
sessionMode: CODEX_STDIO_SESSION_MODE,
|
|
sessionId: session?.sessionId ?? null,
|
|
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
|
codexStdio: true,
|
|
longLivedSession: false,
|
|
durableSession: false,
|
|
writeCapable: false,
|
|
readOnly: false,
|
|
capabilityLevel: "blocked",
|
|
toolPolicy: {
|
|
allowed: ["codex-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start"],
|
|
blocked: ["controlled-readonly-session-registry", "skills.discover-local-fallback", "pwd-local-fallback", "ls-local-fallback", "rg-local-fallback", "cat-local-fallback", "openai-text-fallback"]
|
|
},
|
|
runnerLimitations: ["codex-stdio-required", "no-controlled-readonly-fallback", "no-text-fallback"],
|
|
safety: runnerSafetyContract()
|
|
};
|
|
}
|
|
|
|
function runnerError(code, message, details = {}) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
Object.assign(error, {
|
|
provider: READONLY_RUNNER_PROVIDER,
|
|
model: READONLY_RUNNER_MODEL,
|
|
backend: READONLY_RUNNER_BACKEND,
|
|
sandbox: READONLY_RUNNER_SANDBOX,
|
|
...details
|
|
});
|
|
return error;
|
|
}
|
|
|
|
function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) {
|
|
const text = String(value ?? "");
|
|
if (text.length <= maxLength) return { text, truncated: false };
|
|
return {
|
|
text: `${text.slice(0, maxLength)}\n[output truncated at ${maxLength} bytes]`,
|
|
truncated: true
|
|
};
|
|
}
|
|
|
|
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 chatStatusForError(error) {
|
|
const code = String(error?.code ?? "");
|
|
if (code === "codex_stdio_canceled" || code === "session_canceled") return "canceled";
|
|
if (code === "codex_stdio_timeout" || code === "provider_timeout" || code === "session_timeout") return "timeout";
|
|
if (code === "session_error") return "error";
|
|
if (/timeout|timed out|超时/iu.test(String(error?.message ?? ""))) return "timeout";
|
|
return "failed";
|
|
}
|
|
|
|
function conversationFactsForPrompt(sessionRegistry, conversationId) {
|
|
if (!sessionRegistry || typeof sessionRegistry.getConversationFacts !== "function") return null;
|
|
try {
|
|
return sessionRegistry.getConversationFacts(conversationId);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function createCodeAgentErrorPayload({
|
|
code = "code_agent_failed",
|
|
message = "Code Agent request failed",
|
|
reason,
|
|
traceId = "trc_unassigned",
|
|
conversationId = "cnv_unassigned",
|
|
sessionId = "cnv_unassigned",
|
|
messageId = "msg_unassigned",
|
|
provider = "unassigned",
|
|
model = "unknown",
|
|
backend = "hwlab-cloud-api/code-agent-chat",
|
|
layer,
|
|
retryable,
|
|
capabilityLevel = "blocked",
|
|
route = "/v1/agent/chat",
|
|
toolName = null,
|
|
now
|
|
} = {}) {
|
|
const timestamp = nowIso(now);
|
|
const error = new Error(message);
|
|
Object.assign(error, { code, reason, layer, retryable, provider, model, backend, capabilityLevel, route, toolName });
|
|
const normalized = normalizeChatError(error, { traceId, provider, model, backend, capabilityLevel, route, toolName });
|
|
return decorateChatSessionLifecycle({
|
|
conversationId,
|
|
sessionId,
|
|
messageId,
|
|
status: "failed",
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
traceId,
|
|
provider,
|
|
model,
|
|
backend,
|
|
capabilityLevel,
|
|
error: normalized,
|
|
blocker: normalized.blocker,
|
|
blockers: normalized.blockers
|
|
});
|
|
}
|
|
|
|
function normalizeChatError(error, context = {}) {
|
|
const code = String(error.code || "code_agent_failed");
|
|
const taxonomy = errorTaxonomy(code, error);
|
|
const provider = error.provider ?? context.provider ?? READONLY_RUNNER_PROVIDER;
|
|
const model = error.model ?? context.model ?? READONLY_RUNNER_MODEL;
|
|
const backend = error.backend ?? context.backend ?? READONLY_RUNNER_BACKEND;
|
|
const runnerKind = error.runner?.kind ?? error.runnerKind ?? context.runner?.kind ?? null;
|
|
const traceId = error.traceId ?? context.traceId ?? null;
|
|
const capabilityLevel = error.capabilityLevel ?? context.capabilityLevel ?? "blocked";
|
|
const route = error.route ?? context.route ?? routeForError(code, error);
|
|
const toolName = error.toolName ?? context.toolName ?? toolNameForError(code, error);
|
|
const message = redactText(error.message || taxonomy.message || "Code Agent request failed");
|
|
const lastEvent = error.runnerTrace?.lastEvent ?? context.runnerTrace?.lastEvent ?? error.blocker?.lastEvent ?? null;
|
|
const elapsedMs = error.runnerTrace?.elapsedMs ?? context.runnerTrace?.elapsedMs ?? error.blocker?.elapsedMs ?? null;
|
|
const sessionId = error.session?.sessionId ?? error.sessionId ?? error.blocker?.sessionId ?? context.sessionId ?? null;
|
|
const conversationId = error.conversationId ?? error.blocker?.conversationId ?? error.session?.conversationId ?? context.conversationId ?? null;
|
|
const stage = error.stage ?? error.blocker?.stage ?? lastEvent?.stage ?? taxonomy.layer;
|
|
const blockers = normalizeBlockers(error.blockers, {
|
|
fallbackCode: code,
|
|
layer: taxonomy.layer,
|
|
message,
|
|
userMessage: taxonomy.userMessage,
|
|
retryable: taxonomy.retryable
|
|
});
|
|
const timeoutMs = numberErrorField(error.timeoutMs ?? context.timeoutMs);
|
|
const hardTimeoutMs = numberErrorField(error.hardTimeoutMs ?? context.hardTimeoutMs);
|
|
const idleMs = numberErrorField(error.idleMs ?? context.idleMs);
|
|
const lastActivityAt = safeIsoLike(error.lastActivityAt ?? context.lastActivityAt);
|
|
const waitingFor = redactText(error.waitingFor ?? context.waitingFor ?? "");
|
|
const diagnosis = normalizeDiagnosis(error.diagnosis ?? context.diagnosis ?? error.blocker?.diagnosis ?? null);
|
|
const blocker = structuredBlocker({
|
|
code,
|
|
layer: taxonomy.layer,
|
|
message,
|
|
userMessage: error.userMessage ?? taxonomy.userMessage,
|
|
retryable: error.retryable ?? taxonomy.retryable,
|
|
traceId,
|
|
provider,
|
|
backend,
|
|
runner: error.runner ?? context.runner,
|
|
capabilityLevel,
|
|
route,
|
|
toolName,
|
|
category: taxonomy.category,
|
|
blockers,
|
|
sessionId,
|
|
conversationId,
|
|
stage,
|
|
lastEvent,
|
|
elapsedMs,
|
|
timeoutMs,
|
|
hardTimeoutMs,
|
|
idleMs,
|
|
lastActivityAt,
|
|
waitingFor,
|
|
diagnosis
|
|
});
|
|
const normalized = {
|
|
code,
|
|
layer: taxonomy.layer,
|
|
category: taxonomy.category,
|
|
blocker,
|
|
retryable: blocker.retryable,
|
|
userMessage: blocker.userMessage,
|
|
message,
|
|
traceId,
|
|
provider,
|
|
model,
|
|
backend,
|
|
runner: runnerKind,
|
|
runnerKind,
|
|
capabilityLevel,
|
|
route,
|
|
toolName,
|
|
timeoutMs,
|
|
hardTimeoutMs,
|
|
idleMs,
|
|
lastActivityAt,
|
|
waitingFor,
|
|
diagnosis,
|
|
missingConfig: safeMissingConfig(error),
|
|
blockers
|
|
};
|
|
for (const key of [
|
|
"missingEnv",
|
|
"missingCommands",
|
|
"command",
|
|
"exitCode",
|
|
"providerStatus",
|
|
"stderrSummary",
|
|
"missingTools",
|
|
"nextEvidence",
|
|
"reason"
|
|
]) {
|
|
if (error[key] !== undefined) {
|
|
normalized[key] = sanitizeErrorField(key, error[key]);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function errorTaxonomy(code, error = {}) {
|
|
const catalog = {
|
|
invalid_params: {
|
|
layer: "api",
|
|
category: "api_error",
|
|
retryable: true,
|
|
userMessage: "请求参数不完整或格式不正确,请修正后重试。"
|
|
},
|
|
parse_error: {
|
|
layer: "api",
|
|
category: "api_error",
|
|
retryable: true,
|
|
userMessage: "请求 JSON 无法解析,请修正后重试。"
|
|
},
|
|
provider_unavailable: {
|
|
layer: error.missingEnv?.length || error.missingCommands?.length ? "provider-config" : "provider",
|
|
category: error.missingEnv?.length || error.missingCommands?.length ? "needs_config" : "provider",
|
|
retryable: !(error.missingEnv?.length || error.missingCommands?.length),
|
|
userMessage: error.missingEnv?.length || error.missingCommands?.length
|
|
? "Code Agent provider 配置缺失,需要维护者补齐后才能使用。"
|
|
: "Code Agent provider 暂不可用,可稍后重试。"
|
|
},
|
|
provider_timeout: {
|
|
layer: "provider",
|
|
category: "timeout",
|
|
retryable: true,
|
|
userMessage: "Code Agent provider 响应超时,输入已保留,可稍后重试。"
|
|
},
|
|
codex_cli_binary_missing: {
|
|
layer: "runner",
|
|
category: "needs_config",
|
|
retryable: false,
|
|
userMessage: "Codex CLI binary 未在运行时 PATH 中找到;当前不会安装临时二进制,也不会冒充真实 Codex。"
|
|
},
|
|
session_busy: {
|
|
layer: "session",
|
|
category: "runner_busy",
|
|
retryable: true,
|
|
userMessage: "Code Agent session 正在处理上一轮请求,请稍后重试。"
|
|
},
|
|
session_expired: {
|
|
layer: "session",
|
|
category: "session_blocked",
|
|
retryable: true,
|
|
userMessage: "Code Agent session 已过期,可重新发送建立新的 session。"
|
|
},
|
|
session_reuse_conflict: {
|
|
layer: "session",
|
|
category: "session_blocked",
|
|
retryable: true,
|
|
userMessage: "当前 conversation 与 session 绑定不一致,可新建会话后重试。"
|
|
},
|
|
session_failed: {
|
|
layer: "session",
|
|
category: "runner_blocked",
|
|
retryable: true,
|
|
userMessage: "Code Agent 当前 turn 已失败,session/thread 已保留,可继续发送下一条消息。"
|
|
},
|
|
session_storage_evicted: {
|
|
layer: "session",
|
|
category: "session_blocked",
|
|
retryable: true,
|
|
userMessage: "Code Agent session 存储已失效(PVC 被回收或 TTL 到期),HWLAB 已为你开新 sessionId,可继续发送下一条消息。"
|
|
},
|
|
session_interrupted: {
|
|
layer: "session",
|
|
category: "session_blocked",
|
|
retryable: true,
|
|
userMessage: "Code Agent session 已中断,可重新发送建立新的 session。"
|
|
},
|
|
session_canceled: {
|
|
layer: "session",
|
|
category: "session_blocked",
|
|
retryable: true,
|
|
userMessage: "Code Agent session 已取消,输入和 trace 已保留,可重试上一条消息。"
|
|
},
|
|
session_timeout: {
|
|
layer: "session",
|
|
category: "timeout",
|
|
retryable: true,
|
|
userMessage: "Code Agent 当前 turn 已超时,输入、trace、session/thread 已保留,可继续发送下一条消息。"
|
|
},
|
|
session_error: {
|
|
layer: "session",
|
|
category: "runner_blocked",
|
|
retryable: true,
|
|
userMessage: "Code Agent 当前 turn 返回错误,输入、trace、session/thread 已保留,可继续发送下一条消息。"
|
|
},
|
|
runner_unavailable: {
|
|
layer: "runner",
|
|
category: "runner_blocked",
|
|
retryable: true,
|
|
userMessage: "Code Agent runner 暂不可用,可稍后重试或等待工作区挂载恢复。"
|
|
},
|
|
tool_unavailable: {
|
|
layer: "tool",
|
|
category: "capability_unavailable",
|
|
retryable: false,
|
|
userMessage: "当前 Code Agent 未开放该工具能力。"
|
|
},
|
|
skills_unavailable: {
|
|
layer: "skill-cli",
|
|
category: "needs_config",
|
|
retryable: false,
|
|
userMessage: "Code Agent Skill 清单未挂载或不可读,需要补齐运行时配置。"
|
|
},
|
|
text_chat_only_fallback: {
|
|
layer: "provider",
|
|
category: "fallback",
|
|
retryable: false,
|
|
userMessage: "当前仍是文本 fallback,不具备 runner/session/tool/HWLAB API 控制能力。"
|
|
},
|
|
codex_stdio_blocked: {
|
|
layer: "runner",
|
|
category: "capability_unavailable",
|
|
retryable: false,
|
|
userMessage: "Codex stdio 长会话能力仍受阻,不能作为完整 Code Agent。"
|
|
},
|
|
codex_stdio_protocol_blocked: {
|
|
layer: "runner",
|
|
category: "capability_unavailable",
|
|
retryable: false,
|
|
userMessage: "Codex stdio 协议工具不完整,不能作为完整 Code Agent。"
|
|
},
|
|
codex_stdio_empty_response: {
|
|
layer: "runner",
|
|
category: "runner_blocked",
|
|
retryable: true,
|
|
userMessage: "Codex stdio 未返回有效回复,可稍后重试。"
|
|
},
|
|
codex_stdio_timeout: {
|
|
layer: "runner",
|
|
category: "timeout",
|
|
retryable: true,
|
|
userMessage: "Codex stdio runner 响应超时,输入、sessionId 和 traceId 已保留,可重试。"
|
|
},
|
|
codex_stdio_idle_timeout: {
|
|
layer: "runner",
|
|
category: "timeout",
|
|
retryable: true,
|
|
userMessage: "Codex stdio runner 超过配置时间无新事件,输入、sessionId 和 traceId 已保留,可重试。"
|
|
},
|
|
codex_stdio_hard_timeout: {
|
|
layer: "runner",
|
|
category: "timeout",
|
|
retryable: true,
|
|
userMessage: "Codex stdio runner 达到硬性总时长上限,输入、sessionId 和 traceId 已保留,可重试。"
|
|
},
|
|
codex_stdio_canceled: {
|
|
layer: "runner",
|
|
category: "canceled",
|
|
retryable: true,
|
|
userMessage: "本次 Codex stdio 请求已取消;输入、sessionId 和 traceId 已保留,可重试上一条消息。"
|
|
},
|
|
codex_stdio_command_probe_failed: {
|
|
layer: "runner",
|
|
category: "runner_blocked",
|
|
retryable: true,
|
|
userMessage: "Codex stdio 基础命令探针失败,readiness 不会标为完整 ready;输入已保留,可稍后重试。"
|
|
},
|
|
codex_stdio_failed: {
|
|
layer: "runner",
|
|
category: "runner_blocked",
|
|
retryable: true,
|
|
userMessage: "Codex stdio runner 执行失败;若 trace 显示 transport closed after partial assistant output,请新建 session 重试,并结合 providerTrace、runnerTrace 与 pod restart/rollout 事件判断是否为滚动中断。"
|
|
},
|
|
codex_stdio_network_failed: {
|
|
layer: "runner",
|
|
category: "runner_blocked",
|
|
retryable: true,
|
|
userMessage: "Codex stdio runner 的网络操作失败;输入已保留,可稍后重试。"
|
|
}
|
|
};
|
|
return catalog[code] ?? {
|
|
layer: "api",
|
|
category: "api_error",
|
|
retryable: true,
|
|
userMessage: "Code Agent 请求失败,输入已保留,可稍后重试。"
|
|
};
|
|
}
|
|
|
|
function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers, sessionId = null, conversationId = null, stage = null, lastEvent = null, elapsedMs = null, timeoutMs = null, hardTimeoutMs = null, idleMs = null, lastActivityAt = null, waitingFor = null, diagnosis = null }) {
|
|
return {
|
|
code,
|
|
layer,
|
|
category: category ?? layer,
|
|
retryable: Boolean(retryable),
|
|
summary: redactText(message),
|
|
userMessage,
|
|
traceId: traceId ?? null,
|
|
provider: provider ?? null,
|
|
backend: backend ?? null,
|
|
runner: runner?.kind ?? runner ?? null,
|
|
capabilityLevel: capabilityLevel ?? null,
|
|
route: route ?? null,
|
|
toolName: toolName ?? null,
|
|
sessionId,
|
|
conversationId,
|
|
stage,
|
|
lastEvent,
|
|
elapsedMs,
|
|
timeoutMs,
|
|
hardTimeoutMs,
|
|
idleMs,
|
|
lastActivityAt,
|
|
waitingFor,
|
|
diagnosis,
|
|
blockerCodes: Array.isArray(blockers) ? blockers.map((blocker) => blocker.code).filter(Boolean) : []
|
|
};
|
|
}
|
|
|
|
function normalizeBlockers(blockers, fallback) {
|
|
const items = Array.isArray(blockers) ? blockers : [];
|
|
const normalized = items
|
|
.filter((blocker) => blocker && typeof blocker === "object")
|
|
.map((blocker) => ({
|
|
code: String(blocker.code ?? fallback.fallbackCode),
|
|
layer: blocker.layer ?? fallback.layer,
|
|
category: blocker.category ?? fallback.layer,
|
|
retryable: Boolean(blocker.retryable ?? fallback.retryable),
|
|
summary: redactText(blocker.summary ?? blocker.message ?? fallback.message),
|
|
userMessage: blocker.userMessage ?? blocker.zh ?? fallback.userMessage,
|
|
sourceIssue: blocker.sourceIssue,
|
|
route: blocker.route,
|
|
toolName: blocker.toolName,
|
|
sessionId: blocker.sessionId,
|
|
conversationId: blocker.conversationId,
|
|
stage: blocker.stage,
|
|
lastEvent: blocker.lastEvent,
|
|
currentTraceId: blocker.currentTraceId,
|
|
currentTraceStatus: blocker.currentTraceStatus,
|
|
currentTraceLastEvent: blocker.currentTraceLastEvent,
|
|
currentTraceUpdatedAt: safeIsoLike(blocker.currentTraceUpdatedAt),
|
|
elapsedMs: blocker.elapsedMs,
|
|
timeoutMs: numberErrorField(blocker.timeoutMs),
|
|
hardTimeoutMs: numberErrorField(blocker.hardTimeoutMs),
|
|
idleMs: numberErrorField(blocker.idleMs),
|
|
lastActivityAt: safeIsoLike(blocker.lastActivityAt),
|
|
waitingFor: blocker.waitingFor ? redactText(blocker.waitingFor) : null,
|
|
diagnosis: normalizeDiagnosis(blocker.diagnosis ?? null)
|
|
}));
|
|
if (normalized.length > 0) return normalized;
|
|
return [{
|
|
code: fallback.fallbackCode,
|
|
layer: fallback.layer,
|
|
category: fallback.layer,
|
|
retryable: Boolean(fallback.retryable),
|
|
summary: redactText(fallback.message),
|
|
userMessage: fallback.userMessage
|
|
}];
|
|
}
|
|
|
|
function normalizeDiagnosis(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return {
|
|
code: value.code ? redactText(value.code) : null,
|
|
layer: value.layer ? redactText(value.layer) : null,
|
|
kind: value.kind ? redactText(value.kind) : null,
|
|
summary: value.summary ? redactText(value.summary) : null,
|
|
waitingFor: value.waitingFor ? redactText(value.waitingFor) : null,
|
|
lastActivityAt: safeIsoLike(value.lastActivityAt),
|
|
idleMs: numberErrorField(value.idleMs),
|
|
threadId: value.threadId ? redactText(value.threadId) : null,
|
|
turnId: value.turnId ? redactText(value.turnId) : null,
|
|
lastRoutedEvent: value.lastRoutedEvent && typeof value.lastRoutedEvent === "object" ? {
|
|
type: value.lastRoutedEvent.type ?? null,
|
|
status: value.lastRoutedEvent.status ?? null,
|
|
label: value.lastRoutedEvent.label ?? null,
|
|
stage: value.lastRoutedEvent.stage ?? null,
|
|
toolName: value.lastRoutedEvent.toolName ?? null,
|
|
itemId: value.lastRoutedEvent.itemId ?? null,
|
|
waitingFor: value.lastRoutedEvent.waitingFor ?? null,
|
|
exitCode: numberErrorField(value.lastRoutedEvent.exitCode),
|
|
durationMs: numberErrorField(value.lastRoutedEvent.durationMs)
|
|
} : null
|
|
};
|
|
}
|
|
|
|
function safeMissingConfig(error) {
|
|
return [
|
|
...(Array.isArray(error.missingEnv) ? error.missingEnv : []),
|
|
...(Array.isArray(error.missingCommands) ? error.missingCommands.map((command) => `command:${command}`) : []),
|
|
...(Array.isArray(error.missingTools) ? error.missingTools.map((tool) => `tool:${tool}`) : [])
|
|
].filter((item) => typeof item === "string" && /^[A-Za-z0-9_./:-]+$/u.test(item));
|
|
}
|
|
|
|
function numberErrorField(value) {
|
|
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null;
|
|
}
|
|
|
|
function safeIsoLike(value) {
|
|
const text = typeof value === "string" ? value.trim() : "";
|
|
if (!text || text.length > 80) return null;
|
|
return /^[0-9T:Z.+-]+$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function resolveRunnerWorkspace(env = process.env, options = {}) {
|
|
const configured = firstNonEmpty(
|
|
options.workspace,
|
|
env.HWLAB_CODE_AGENT_WORKSPACE,
|
|
env.HWLAB_RUNNER_WORKSPACE,
|
|
env.WORKSPACE
|
|
);
|
|
return path.resolve(configured || repoRoot);
|
|
}
|
|
|
|
function resolveSkillDirs(env = process.env, options = {}) {
|
|
const configured = Array.isArray(options.skillsDirs)
|
|
? options.skillsDirs
|
|
: String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, ""))
|
|
.split(/[,;]/u)
|
|
.flatMap((part) => part.split(path.delimiter));
|
|
const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1";
|
|
if (strict) {
|
|
return [...new Set(configured
|
|
.filter((dir) => typeof dir === "string" && dir.trim())
|
|
.map((dir) => path.resolve(dir.trim())))];
|
|
}
|
|
return [path.resolve("/app/skills")];
|
|
}
|
|
|
|
function sanitizeErrorField(key, value) {
|
|
if (key === "missingEnv" || key === "missingCommands" || key === "missingTools") {
|
|
return Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => redactText(item)) : [];
|
|
}
|
|
if (key === "providerStatus" || key === "exitCode") return value;
|
|
return redactText(value);
|
|
}
|
|
|
|
function routeForError(code, error = {}) {
|
|
if (error.route !== undefined) return error.route;
|
|
if (String(code).startsWith("hardware_")) return HARDWARE_INVOKE_SHELL_METHOD;
|
|
return null;
|
|
}
|
|
|
|
function toolNameForError(code, error = {}) {
|
|
if (error.toolName !== undefined) return error.toolName;
|
|
if (String(code).startsWith("hardware_")) return HARDWARE_INVOKE_SHELL_METHOD;
|
|
return null;
|
|
}
|
|
|
|
function badRequest(message) {
|
|
const error = new Error(message);
|
|
error.code = "invalid_params";
|
|
return error;
|
|
}
|
|
|
|
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 resolveConversationSessionIds(params = {}) {
|
|
const requestedConversationId = cleanProtocolId(params.conversationId, "cnv");
|
|
const requestedSessionId = cleanProtocolId(params.sessionId, "ses");
|
|
if (requestedConversationId && requestedSessionId) {
|
|
return {
|
|
conversationId: requestedConversationId,
|
|
sessionId: requestedSessionId,
|
|
requestedSessionId
|
|
};
|
|
}
|
|
if (requestedConversationId) {
|
|
return {
|
|
conversationId: requestedConversationId,
|
|
sessionId: requestedConversationId,
|
|
requestedSessionId: null
|
|
};
|
|
}
|
|
if (requestedSessionId) {
|
|
const conversationId = `cnv_${requestedSessionId.replace(/^[a-z][a-z0-9]*_/u, "")}`;
|
|
return {
|
|
conversationId,
|
|
sessionId: requestedSessionId,
|
|
requestedSessionId
|
|
};
|
|
}
|
|
const conversationId = `cnv_${randomUUID()}`;
|
|
return {
|
|
conversationId,
|
|
sessionId: conversationId,
|
|
requestedSessionId: null
|
|
};
|
|
}
|
|
|
|
function nowIso(now) {
|
|
return typeof now === "function" ? now() : new Date().toISOString();
|
|
}
|
|
|
|
function firstDefined(...values) {
|
|
return values.find((value) => value !== undefined && value !== null);
|
|
}
|
|
|
|
function formatM3Value(value) {
|
|
if (value === undefined || value === null) return "未产生/不可证明";
|
|
return String(value);
|
|
}
|
|
|
|
function firstNonEmpty(...values) {
|
|
for (const value of values) {
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
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 redactUrl(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
url.username = "";
|
|
url.password = "";
|
|
return url.toString();
|
|
} catch {
|
|
return redactText(String(value ?? "")).replace(/\/\/[^/@]+@/u, "//***@");
|
|
}
|
|
}
|