518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
||
|
||
import { containsForbiddenPersistentContent } from "../utils/persistencePolicy.js";
|
||
import { logger } from "../logger.js";
|
||
import { type QuestionAnswers } from "../runtime/opencode.js";
|
||
|
||
export type PermissionRequestPayload = {
|
||
session_id: string;
|
||
request_id: string;
|
||
permission: string;
|
||
patterns: string[];
|
||
target?: string;
|
||
always: string[];
|
||
tool?: {
|
||
messageID: string;
|
||
callID: string;
|
||
};
|
||
created_at: number;
|
||
};
|
||
|
||
type QuestionOptionPayload = {
|
||
label: string;
|
||
description: string;
|
||
};
|
||
|
||
type QuestionInfoPayload = {
|
||
header: string;
|
||
question: string;
|
||
options: QuestionOptionPayload[];
|
||
multiple?: boolean;
|
||
custom?: boolean;
|
||
};
|
||
|
||
export type QuestionRequestPayload = {
|
||
session_id: string;
|
||
request_id: string;
|
||
questions: QuestionInfoPayload[];
|
||
tool?: {
|
||
messageID: string;
|
||
callID: string;
|
||
};
|
||
created_at: number;
|
||
};
|
||
|
||
export type TodoItemPayload = {
|
||
id: string;
|
||
content: string;
|
||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||
priority?: "low" | "medium" | "high";
|
||
created_at?: number;
|
||
updated_at?: number;
|
||
};
|
||
|
||
export type TodoUpdatePayload = {
|
||
session_id: string;
|
||
message_id?: string;
|
||
todos: TodoItemPayload[];
|
||
created_at: number;
|
||
};
|
||
|
||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||
|
||
const toolLabels: Record<string, string> = {
|
||
present_view: "展示地图与图表",
|
||
memory_manager: "记忆写入",
|
||
session_search: "历史会话检索",
|
||
skill_manager: "流程沉淀",
|
||
};
|
||
|
||
export const logDevelopmentDebug = (
|
||
message: string,
|
||
metadata: Record<string, unknown>,
|
||
) => {
|
||
if (!isDevelopmentDebugLoggingEnabled) {
|
||
return;
|
||
}
|
||
logger.info(metadata, message);
|
||
};
|
||
|
||
export const getErrorMessage = (error: {
|
||
name: string;
|
||
data?: { message?: string };
|
||
}) => error.data?.message ?? error.name;
|
||
|
||
export const getUnknownErrorMessage = (error: unknown) => {
|
||
if (
|
||
typeof error === "object" &&
|
||
error !== null &&
|
||
"name" in error &&
|
||
typeof error.name === "string"
|
||
) {
|
||
const maybeData = "data" in error ? error.data : undefined;
|
||
return getErrorMessage({
|
||
name: error.name,
|
||
data:
|
||
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
|
||
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
|
||
: undefined,
|
||
});
|
||
}
|
||
return error instanceof Error ? error.message : String(error);
|
||
};
|
||
|
||
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||
|
||
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
||
if (isObjectRecord(value)) {
|
||
return value;
|
||
}
|
||
if (typeof value === "string") {
|
||
try {
|
||
const parsed = JSON.parse(value) as unknown;
|
||
return isObjectRecord(parsed) ? parsed : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
return {};
|
||
};
|
||
|
||
// New tools use reason; aliases are read-only compatibility for existing events.
|
||
const reasonKeys = ["reason", "request_reason", "why", "purpose", "rationale"];
|
||
|
||
export const extractRequestReason = (params: Record<string, unknown>) => {
|
||
for (const key of reasonKeys) {
|
||
const value = params[key];
|
||
if (typeof value === "string") {
|
||
const normalized = value.trim();
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
}
|
||
return "";
|
||
};
|
||
|
||
export const isSkillEvent = (event: OpencodeEvent) =>
|
||
event.type.toLowerCase().includes("skill");
|
||
|
||
export const extractSkillAuditInfo = (event: OpencodeEvent) => {
|
||
const payload = isObjectRecord(event.properties)
|
||
? (event.properties as Record<string, unknown>)
|
||
: {};
|
||
const candidateName =
|
||
typeof payload.skill === "string"
|
||
? payload.skill
|
||
: typeof payload.skillName === "string"
|
||
? payload.skillName
|
||
: typeof payload.name === "string"
|
||
? payload.name
|
||
: event.type;
|
||
const reason = extractRequestReason(payload);
|
||
return {
|
||
name: candidateName,
|
||
reason,
|
||
payload,
|
||
};
|
||
};
|
||
|
||
export const hasToolParams = (params: Record<string, unknown>) =>
|
||
Object.keys(params).length > 0;
|
||
|
||
export const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
|
||
"properties" in event &&
|
||
typeof event.properties === "object" &&
|
||
event.properties !== null &&
|
||
"sessionID" in event.properties &&
|
||
event.properties.sessionID === sessionId;
|
||
|
||
export const isPermissionAskedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "permission.asked" }> =>
|
||
event.type === "permission.asked";
|
||
|
||
export const isPermissionV2AskedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "permission.v2.asked" }> =>
|
||
event.type === "permission.v2.asked";
|
||
|
||
export const isPermissionRepliedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "permission.replied" }> =>
|
||
event.type === "permission.replied";
|
||
|
||
export const isPermissionV2RepliedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "permission.v2.replied" }> =>
|
||
event.type === "permission.v2.replied";
|
||
|
||
export const isQuestionAskedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "question.asked" }> =>
|
||
event.type === "question.asked";
|
||
|
||
export const isQuestionV2AskedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "question.v2.asked" }> =>
|
||
event.type === "question.v2.asked";
|
||
|
||
export const isQuestionRepliedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "question.replied" }> =>
|
||
event.type === "question.replied";
|
||
|
||
export const isQuestionV2RepliedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "question.v2.replied" }> =>
|
||
event.type === "question.v2.replied";
|
||
|
||
export const isQuestionRejectedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "question.rejected" }> =>
|
||
event.type === "question.rejected";
|
||
|
||
export const isQuestionV2RejectedEvent = (
|
||
event: OpencodeEvent,
|
||
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
|
||
event.type === "question.v2.rejected";
|
||
|
||
export const buildPermissionDetail = (
|
||
event: Extract<OpencodeEvent, { type: "permission.asked" }>,
|
||
) => {
|
||
const patterns = event.properties.patterns.length
|
||
? event.properties.patterns.join(", ")
|
||
: event.properties.permission;
|
||
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
|
||
};
|
||
|
||
export const buildPermissionV2Detail = (
|
||
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
|
||
) => {
|
||
const resources = event.properties.resources.length
|
||
? event.properties.resources.join(", ")
|
||
: event.properties.action;
|
||
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
|
||
};
|
||
|
||
export const normalizeQuestionPayload = (
|
||
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
|
||
clientSessionId: string,
|
||
): QuestionRequestPayload => ({
|
||
session_id: clientSessionId,
|
||
request_id: event.properties.id,
|
||
questions: event.properties.questions.map((question) => ({
|
||
header: question.header,
|
||
question: question.question,
|
||
options: question.options.map((option) => ({
|
||
label: option.label,
|
||
description: option.description,
|
||
})),
|
||
multiple: question.multiple,
|
||
custom: question.custom,
|
||
})),
|
||
tool: event.properties.tool,
|
||
created_at: Date.now(),
|
||
});
|
||
|
||
export const normalizeQuestionAnswers = (answers: QuestionAnswers | undefined) =>
|
||
Array.isArray(answers)
|
||
? answers.map((answer) =>
|
||
Array.isArray(answer)
|
||
? answer.filter((item): item is string => typeof item === "string")
|
||
: [],
|
||
)
|
||
: [];
|
||
|
||
const questionToolNames = new Set(["question", "request_user_input"]);
|
||
|
||
const normalizeQuestionOptions = (value: unknown): QuestionOptionPayload[] =>
|
||
Array.isArray(value)
|
||
? value.filter(isObjectRecord).map((option) => ({
|
||
label: typeof option.label === "string" ? option.label : "",
|
||
description:
|
||
typeof option.description === "string" ? option.description : "",
|
||
})).filter((option) => option.label.trim().length > 0)
|
||
: [];
|
||
|
||
const normalizeToolQuestionInfo = (value: unknown): QuestionInfoPayload | undefined => {
|
||
if (!isObjectRecord(value) || typeof value.question !== "string") {
|
||
return undefined;
|
||
}
|
||
const question = value.question.trim();
|
||
if (!question) {
|
||
return undefined;
|
||
}
|
||
return {
|
||
header:
|
||
typeof value.header === "string" && value.header.trim()
|
||
? value.header
|
||
: "补充信息",
|
||
question,
|
||
options: normalizeQuestionOptions(value.options),
|
||
multiple: typeof value.multiple === "boolean" ? value.multiple : undefined,
|
||
custom: typeof value.custom === "boolean" ? value.custom : undefined,
|
||
};
|
||
};
|
||
|
||
export const normalizeQuestionToolPayload = (
|
||
part: Extract<Part, { type: "tool" }>,
|
||
params: Record<string, unknown>,
|
||
clientSessionId: string,
|
||
): QuestionRequestPayload | undefined => {
|
||
// Native V2 question tools create a separate frm_ request. A call ID is not a form ID.
|
||
if ("metadata" in part.state && part.state.metadata?.native_form === true) {
|
||
return undefined;
|
||
}
|
||
if (!questionToolNames.has(part.tool)) {
|
||
return undefined;
|
||
}
|
||
const questions = Array.isArray(params.questions)
|
||
? params.questions
|
||
.map(normalizeToolQuestionInfo)
|
||
.filter((question): question is QuestionInfoPayload => Boolean(question))
|
||
: [];
|
||
if (questions.length === 0) {
|
||
return undefined;
|
||
}
|
||
return {
|
||
session_id: clientSessionId,
|
||
request_id: part.callID || part.id,
|
||
questions,
|
||
tool: {
|
||
messageID: part.messageID,
|
||
callID: part.callID,
|
||
},
|
||
created_at: Date.now(),
|
||
};
|
||
};
|
||
|
||
export const normalizeTodoStatus = (status: string): TodoItemPayload["status"] => {
|
||
if (status === "in_progress" || status === "completed" || status === "cancelled") {
|
||
return status;
|
||
}
|
||
return "pending";
|
||
};
|
||
|
||
export const normalizeTodoPriority = (
|
||
priority: string,
|
||
): TodoItemPayload["priority"] | undefined => {
|
||
if (priority === "low" || priority === "medium" || priority === "high") {
|
||
return priority;
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
export const collectTextContent = (parts: Part[]) =>
|
||
parts
|
||
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
||
.map((part) => part.text)
|
||
.join("");
|
||
|
||
export const normalizeToolStatus = (status: string) => {
|
||
if (status === "completed") return "completed";
|
||
if (status === "error") return "error";
|
||
return "running";
|
||
};
|
||
|
||
/** Browser-visible tool inputs: retain useful values without credentials or large payloads. */
|
||
export const sanitizeToolProgressInput = (params: Record<string, unknown>): Record<string, unknown> => {
|
||
let remaining = 16_000;
|
||
let nodes = 512;
|
||
const visit = (value: unknown, depth: number): unknown => {
|
||
if (remaining <= 0 || nodes-- <= 0) return "[truncated]";
|
||
if (typeof value === "string") {
|
||
if (containsForbiddenPersistentContent(value)) return "[redacted]";
|
||
const limit = Math.min(remaining, 4_000);
|
||
remaining -= Math.min(value.length, limit);
|
||
return value.length > limit ? `${value.slice(0, limit)}… [truncated]` : value;
|
||
}
|
||
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
|
||
if (depth >= 5) return "[truncated]";
|
||
if (Array.isArray(value)) {
|
||
const items = value.slice(0, 64).map((item) => visit(item, depth + 1));
|
||
if (value.length > 64) items.push("[truncated]");
|
||
return items;
|
||
}
|
||
if (value && typeof value === "object") {
|
||
const entries = Object.entries(value).slice(0, 64).map(([rawKey, item]) => {
|
||
const key = rawKey.slice(0, 100);
|
||
remaining -= key.length;
|
||
return [key, /authorization|cookie|password|secret|token|api[_-]?key|credential|headers?/i.test(key)
|
||
? "[redacted]" : visit(item, depth + 1)];
|
||
});
|
||
if (Object.keys(value).length > 64) entries.push(["…", "[truncated]"]);
|
||
return Object.fromEntries(entries);
|
||
}
|
||
return "[unsupported]";
|
||
};
|
||
return visit(params, 0) as Record<string, unknown>;
|
||
};
|
||
|
||
const formatProgressValue = (value: unknown): string => {
|
||
if (typeof value === "string") {
|
||
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
||
}
|
||
if (
|
||
typeof value === "number" ||
|
||
typeof value === "boolean" ||
|
||
value === null ||
|
||
value === undefined
|
||
) {
|
||
return String(value);
|
||
}
|
||
try {
|
||
const serialized = JSON.stringify(value);
|
||
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
|
||
} catch {
|
||
return "[unserializable]";
|
||
}
|
||
};
|
||
|
||
const normalizeProgressText = (chunks: string[]) =>
|
||
chunks.join("").replace(/\s+/g, " ").trim();
|
||
|
||
const truncateProgressText = (text: string, maxLength: number) =>
|
||
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
||
|
||
const summarizeToolParams = (params: Record<string, unknown>) => {
|
||
const ignoredKeys = new Set(reasonKeys);
|
||
const summary = Object.entries(params)
|
||
.filter(([key]) => !ignoredKeys.has(key))
|
||
.slice(0, 4)
|
||
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
|
||
.join(", ");
|
||
|
||
return summary || "无附加参数";
|
||
};
|
||
|
||
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
|
||
if (status.type === "retry") {
|
||
return status.message
|
||
? `模型请求需要重试,原因:${status.message}`
|
||
: "模型请求正在重试,等待下一次响应。";
|
||
}
|
||
if (status.type === "busy") {
|
||
return status.message
|
||
? `Agent 正在处理中:${status.message}`
|
||
: "Agent 正在执行推理、工具调用或结果整理。";
|
||
}
|
||
if (status.type === "idle") {
|
||
return status.message
|
||
? `Agent 已空闲:${status.message}`
|
||
: "当前会话暂时没有待处理任务。";
|
||
}
|
||
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
|
||
};
|
||
|
||
export const buildReasoningProgressDetail = (
|
||
chunks: string[],
|
||
ended?: string | number | Date | null,
|
||
) => {
|
||
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
|
||
if (ended) {
|
||
return reasoningText
|
||
? `推理过程:${reasoningText}`
|
||
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
|
||
}
|
||
return reasoningText
|
||
? `正在推理:${reasoningText}`
|
||
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
|
||
};
|
||
|
||
export const buildToolProgressDetail = (
|
||
tool: string,
|
||
status: string,
|
||
params: Record<string, unknown>,
|
||
reason: string,
|
||
error?: string,
|
||
output?: string,
|
||
) => {
|
||
const toolName = toolLabels[tool] ?? tool;
|
||
const reasonText = reason ? `;说明:${containsForbiddenPersistentContent(reason) ? "[redacted]" : reason}` : "";
|
||
const paramsText = `;关键参数:${summarizeToolParams(sanitizeToolProgressInput(params))}`;
|
||
|
||
if (status === "error") {
|
||
const errorText = error ? `;错误:${containsForbiddenPersistentContent(error) ? "[redacted]" : error}` : "";
|
||
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
|
||
}
|
||
if (status === "completed") {
|
||
const calculation = calculationProgress(output);
|
||
if (calculation) return `${toolName}:${calculation.label};计算任务:${calculation.jobId}${reasonText}${paramsText}`;
|
||
return `${toolName} 已执行完成${reasonText}${paramsText}`;
|
||
}
|
||
if (status === "pending") {
|
||
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
|
||
}
|
||
return `${toolName} 正在执行${reasonText}${paramsText}`;
|
||
};
|
||
|
||
const calculationProgress = (output?: string) => {
|
||
if (!output || output.length > 100_000) return null;
|
||
try {
|
||
const value = JSON.parse(output);
|
||
if (value?.semantics !== "task" || typeof value.data?.job_id !== "string" ||
|
||
!/^[0-9a-f-]{36}$/i.test(value.data.job_id)) return null;
|
||
const labels: Record<string, string> = {
|
||
queued: "已提交,等待计算", running: "后台计算中", cancel_requested: "已请求取消,等待计算停止",
|
||
succeeded: "计算已完成", failed: "计算失败", cancelled: "计算已取消",
|
||
interrupted: "计算已中断", timed_out: "计算执行超时",
|
||
};
|
||
const label = labels[value.data.status];
|
||
return label ? { label, jobId: value.data.job_id } : null;
|
||
} catch { return null; }
|
||
};
|
||
|
||
export const getToolProgressTitle = (tool: string, status: string, output?: string) => {
|
||
const toolName = toolLabels[tool] ?? tool;
|
||
if (status === "completed") {
|
||
const calculation = calculationProgress(output);
|
||
if (calculation) return `${toolName}:${calculation.label}`;
|
||
}
|
||
if (status === "completed") return `${toolName} 已完成`;
|
||
if (status === "error") return `${toolName} 执行失败`;
|
||
if (status === "pending") return `准备调用 ${toolName}`;
|
||
return `正在调用 ${toolName}`;
|
||
};
|